Compare commits

..

5 Commits

Author SHA1 Message Date
Pepijn 4045589246 docs(rtc): use 15-step training delay 2026-08-04 21:09:12 +02:00
Pepijn 80761e8a7a feat(pi05): add training-time RTC to SE3 branch 2026-08-04 21:07:26 +02:00
Pepijn b0cceb2a5f feat(pi05): support continuous 6D SE(3) actions (#4132)
* feat(pi05): add continuous 6D SE3 actions

* fix(processors): align rotation 6D with UMI rows
2026-07-27 12:16:33 +02:00
Pepijn 8e12a5351a feat(pi05): compose relative poses in SE3 2026-07-21 20:43:04 +02:00
Pepijn 7e1077f19a feat(pi05): synthesize relative proprioceptive history 2026-07-18 23:56:19 +02:00
27 changed files with 2052 additions and 913 deletions
+7 -6
View File
@@ -228,12 +228,13 @@ lerobot-rollout \
--device=cuda
```
| Flag | Description |
| ------------------------------------------- | -------------------------------------------------------------- |
| `--inference.rtc.execution_horizon` | Steps to blend with previous chunk (default: varies by policy) |
| `--inference.rtc.max_guidance_weight` | Consistency enforcement strength (default: varies by policy) |
| `--inference.rtc.prefix_attention_schedule` | Blend schedule: `LINEAR`, `EXP`, `ONES`, `ZEROS` |
| `--inference.queue_threshold` | Max queue size before backpressure (default: 30) |
| Flag | Description |
| ------------------------------------------- | ------------------------------------------------------------------------------- |
| `--inference.rtc.execution_horizon` | Steps to blend with previous chunk (default: varies by policy) |
| `--inference.rtc.mode` | `guided` (default) or trained-prefix `trained` for compatible Pi0.5 checkpoints |
| `--inference.rtc.max_guidance_weight` | Consistency enforcement strength (default: varies by policy) |
| `--inference.rtc.prefix_attention_schedule` | Blend schedule: `LINEAR`, `EXP`, `ONES`, `ZEROS` |
| `--inference.queue_threshold` | Backpressure threshold; trained RTC requires at least its maximum delay |
See the [Real-Time Chunking](./rtc) guide for details on tuning RTC parameters.
+57 -1
View File
@@ -1,6 +1,6 @@
# Real-Time Chunking (RTC)
Real-Time Chunking (RTC) is an inference-time method that allows large, flow-matching based robotic policies, such as [Pi0](./pi0), [Pi0.5](./pi05), and [SmolVLA](./smolvla), to produce smooth, continuous, and reactive motion despite having high inference latency.
Real-Time Chunking (RTC) allows large, flow-matching based robotic policies, such as [Pi0](./pi0), [Pi0.5](./pi05), and [SmolVLA](./smolvla), to produce smooth, continuous, and reactive motion despite having high inference latency. LeRobot provides the original inference-time guided mode and, for compatible Pi0.5 checkpoints, training-time action conditioning with cheap hard-prefix inference.
These policies generate chunks of future actions (e.g., 50 steps at a time) instead of single actions.
Because the models are large, producing each chunk takes longer than the time it takes the robot to execute it.
@@ -92,6 +92,15 @@ for step in range(num_steps):
`RTCConfig` has the following parameters to tune:
**`mode`** selects the action-prefix conditioning method:
- `guided` (default) applies the original Jacobian guidance during denoising and works with ordinary flow-matching checkpoints.
- `trained` hard-inpaints the previous chunk's prefix with per-action flow timesteps. It currently requires a Pi0.5 checkpoint trained with `policy.rtc_training_max_delay > 0` and avoids the guidance backward pass.
For trained mode, both `execution_horizon` and the rollout backend's
`inference.queue_threshold` must be at least the checkpoint's
`rtc_training_max_delay`; rollout validates this before connecting the robot.
**`execution_horizon`**: How many timesteps from the previous chunk to maintain consistency with. Higher values mean smoother transitions but potentially less reactivity.
Typical values: 8-12 steps
@@ -111,6 +120,27 @@ RTCConfig(execution_horizon=10)
**`inference_delay`**: How many timesteps of inference latency your system has. This is passed to `predict_action_chunk()` rather than the config, since it may vary at runtime.
## Training Pi0.5 for Trained RTC
Set the maximum prefix delay when fine-tuning Pi0.5:
```bash
lerobot-train \
--policy.type=pi05 \
--policy.pretrained_path=lerobot/pi05_base \
--policy.rtc_training_max_delay=15 \
--dataset.repo_id=${HF_USERNAME}/dataset_repo_id \
--output_dir=outputs/pi05_training_rtc
```
Each example samples a clean prefix from zero through the configured maximum;
the flow loss is computed only on the remaining postfix. Choose the maximum as
approximately `ceil(p95 end-to-end inference latency * control frequency)` and
keep it smaller than `policy.chunk_size`. Setting the value to zero preserves
ordinary Pi0.5 training and existing checkpoints remain compatible with guided
RTC. At a 50 Hz control rate, 15 steps cover up to 300 ms of end-to-end
inference latency.
## Testing RTC Offline
Before running on a real robot, test RTC with dataset samples to visualize how it works:
@@ -124,6 +154,10 @@ python examples/rtc/eval_dataset.py \
--device=cuda
```
Add `--rtc.mode=trained` when evaluating a compatible training-time RTC Pi0.5
checkpoint. Unsupported policies reject trained mode instead of falling back to
guided RTC.
The script generates a visualization of the denoising process, comparing standard generation (left) with RTC (right). In the RTC plots, you can see how the first few steps (blue/purple lines) are guided to match the red ground truth trajectory (previous chunk's tail), ensuring a smooth transition between chunks.
<p align="center">
@@ -141,6 +175,7 @@ lerobot-rollout \
--strategy.type=base \
--policy.path=${HF_USERNAME}/policy_repo_id \
--inference.type=rtc \
--inference.rtc.mode=guided \
--inference.rtc.execution_horizon=10 \
--inference.rtc.max_guidance_weight=10.0 \
--robot.type=so100_follower \
@@ -151,6 +186,25 @@ lerobot-rollout \
--device=cuda
```
For a training-time RTC Pi0.5 checkpoint, change the mode to `trained`. The
checkpoint records its maximum supported delay, and rollout validates measured
latency against it:
```bash
lerobot-rollout \
--strategy.type=base \
--policy.path=${HF_USERNAME}/pi05_training_rtc \
--inference.type=rtc \
--inference.rtc.mode=trained \
--inference.rtc.execution_horizon=15 \
--inference.queue_threshold=15 \
--robot.type=so100_follower \
--robot.port=/dev/tty.usbmodem58FA0834591 \
--task="Move green small object into the purple platform" \
--duration=120 \
--device=cuda
```
## How It Differs from the Async Inference in LeRobot
Both RTC and [async inference](./async) improve real-time robot control, but they solve different problems.
@@ -189,3 +243,5 @@ See `examples/rtc/eval_dataset.py` for a complete example of offline RTC visuali
- [Smooth-As-Butter Robot Policies](https://alexander-soare.github.io/robotics/2025/08/05/smooth-as-butter-robot-policies.html) - Excellent technical explanation with real robot results
- [Physical Intelligence - Real-Time Chunking](https://www.physicalintelligence.company/research/real_time_chunking) - Original paper and research
- [Kinetix RTC Implementation](https://github.com/Physical-Intelligence/real-time-chunking-kinetix) - Reference implementation from Physical Intelligence
- [Training-Time Action Conditioning](https://arxiv.org/abs/2512.05964) - Efficient RTC with clean-prefix conditioning during training
- [RLDX-1](https://github.com/RLWRLD/RLDX-1) - PyTorch reference used for the training-time RTC integration
-489
View File
@@ -1,489 +0,0 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# 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.
"""
SLURM-distributed recomputation of a LeRobotDataset's ``meta/stats.json``.
Modified copy of lerobot's examples/dataset/slurm_recompute_stats.py
(feat/recompute-stats-readonly-and-visual branch) with cluster-friendly additions:
1. --qos : pass a SLURM QoS through to every worker's sbatch.
2. --venv-path : activate a venv on each worker before the python step.
3. --env-command : raw shell snippet injected before the python step (e.g. to
export HF_LEROBOT_HOME). Runs in addition to --venv-path.
4. --chain-aggregate : submit ``aggregate`` with an afterok dependency on
``compute`` so it only runs once all shards exist
(no manual squeue-wait, no gap/overlap race).
5. --update-episode-stats : in ``aggregate``, also rewrite the per-episode stats in the
episodes parquet so they stay consistent with meta/stats.json
(default: only stats.json is written).
Data access: no filesystem mount. Point HF_LEROBOT_HOME at a node-visible shared
cache (e.g. /fsx/$USER/.cache) so the dataset downloads once and all workers read
it. This is the download route; the source dataset is fetched from the Hub on the
CPU workers.
IMPORTANT — how to run (do NOT sbatch this file):
Run it as a normal python process on the LOGIN node. datatrove submits the
workers for you. The reference copy (--new-root) is built on the login node and
references the shared HF cache, so /fsx must be visible there (it is).
Requires: pip install 'lerobot[dataset]' datatrove
Example (single command, compute then dependent aggregate):
export HF_LEROBOT_HOME=/fsx/$USER/.cache
python slurm_recompute_stats_patched.py compute \
--repo-id behavior-1k/2026-challenge-demos \
--new-root /fsx/$USER/behavior-1k_recomputed \
--shard-dir /fsx/$USER/behavior-1k_recomputed/stats_shards \
--logs-dir /fsx/$USER/logs/recompute \
--skip-image-video 0 \
--workers 250 \
--partition hopper-cpu \
--qos normal \
--cpus-per-task 8 --mem-per-cpu 4G \
--venv-path /fsx/$USER/venvs/lerobot/bin/activate \
--env-command 'export HF_LEROBOT_HOME=/fsx/'"$USER"'/.cache' \
--chain-aggregate
REHEARSE FIRST with --workers 2 --skip-image-video 1 and inspect one worker's log
under --logs-dir to confirm QoS was accepted and a numeric stats.json is written.
"""
import argparse
from pathlib import Path
from datatrove.executor import LocalPipelineExecutor
from datatrove.executor.slurm import SlurmPipelineExecutor
from datatrove.pipeline.base import PipelineStep
class ComputeEpisodeStatsShards(PipelineStep):
"""Each worker computes per-episode stats for its ``episodes[rank::world_size]`` shard."""
def __init__(self, repo_id, root, new_root, skip_image_video, shard_dir, video_backend=None):
super().__init__()
self.repo_id = repo_id
self.root = root
self.new_root = new_root
self.skip_image_video = skip_image_video
self.shard_dir = shard_dir
self.video_backend = video_backend
def run(self, data=None, rank: int = 0, world_size: int = 1):
# NOTE: this method is pickled and executed on a worker, where this script's module
# globals are NOT available. Keep it self-contained: import locally and don't reference
# module-level helpers/constants.
import logging
import pickle
from pathlib import Path
from lerobot.datasets import LeRobotDataset, compute_dataset_episode_stats
from lerobot.utils.utils import init_logging
init_logging()
load_kwargs = {"video_backend": self.video_backend} if self.video_backend else {}
root = self.new_root if self.new_root and Path(self.new_root).exists() else self.root
dataset = LeRobotDataset(self.repo_id, root=root, **load_kwargs)
my_episodes = list(range(dataset.meta.total_episodes))[rank::world_size]
if not my_episodes:
logging.info(f"Rank {rank}: no episodes assigned")
return
logging.info(f"Rank {rank}: {len(my_episodes)} / {dataset.meta.total_episodes} episodes")
episode_stats = compute_dataset_episode_stats(
dataset,
episode_indices=my_episodes,
skip_image_video=self.skip_image_video,
)
shard_dir = Path(self.shard_dir)
shard_dir.mkdir(parents=True, exist_ok=True)
out = shard_dir / f"episode_stats_{rank:05d}.pkl"
with open(out, "wb") as f:
pickle.dump(episode_stats, f)
logging.info(f"Rank {rank}: saved {len(episode_stats)} episode stats to {out}")
class AggregateEpisodeStats(PipelineStep):
"""Merge all per-episode stat shards into meta/stats.json."""
def __init__(
self,
repo_id,
root,
new_root,
shard_dir,
push_to_hub=False,
video_backend=None,
update_episode_stats=False,
):
super().__init__()
self.repo_id = repo_id
self.root = root
self.new_root = new_root
self.shard_dir = shard_dir
self.push_to_hub = push_to_hub
self.video_backend = video_backend
self.update_episode_stats = update_episode_stats
def run(self, data=None, rank: int = 0, world_size: int = 1):
# NOTE: pickled and executed on a worker; keep self-contained (see ComputeEpisodeStatsShards.run).
import logging
import pickle
from pathlib import Path
from lerobot.datasets import LeRobotDataset, aggregate_episode_stats
from lerobot.utils.utils import init_logging
init_logging()
if rank != 0:
return
shard_dir = Path(self.shard_dir)
shards = sorted(shard_dir.glob("episode_stats_*.pkl"))
if not shards:
raise FileNotFoundError(f"No episode stat shards found in {shard_dir}")
# Shards map episode_index -> stats; merging by key makes a dropped shard show up as a
# missing episode and a re-run shard overwrite rather than double-count.
all_episode_stats = {}
for shard in shards:
with open(shard, "rb") as f:
all_episode_stats.update(pickle.load(f))
logging.info(f"Aggregating {len(all_episode_stats)} episode stats from {len(shards)} shards")
load_kwargs = {"video_backend": self.video_backend} if self.video_backend else {}
root = self.new_root if self.new_root and Path(self.new_root).exists() else self.root
dataset = LeRobotDataset(self.repo_id, root=root, **load_kwargs)
# Aggregation is order-independent, so the only way sharding changes the result is a
# gap (dropped shard) or an overlap (episode counted twice). Verify the shards cover
# every episode exactly once before writing stats.json.
expected_episodes = dataset.meta.total_episodes
if len(all_episode_stats) != expected_episodes:
raise ValueError(
f"Expected {expected_episodes} per-episode stats (one per episode) but got "
f"{len(all_episode_stats)} across {len(shards)} shards. A compute shard is likely "
"missing or was written more than once; re-run the failed shards before aggregating."
)
# Frame-count check catches the case where a duplicate and a gap cancel out in the
# episode count: summed per-episode frame counts must equal the dataset's total frames.
stats_values = list(all_episode_stats.values())
numeric_key = next(
(
k
for k, v in dataset.meta.features.items()
if v["dtype"] not in ("image", "video", "string") and stats_values and k in stats_values[0]
),
None,
)
if numeric_key is not None:
total_frames = sum(int(s[numeric_key]["count"][0]) for s in stats_values)
if total_frames != dataset.meta.total_frames:
raise ValueError(
f"Summed frame count from shards ({total_frames}) != dataset total_frames "
f"({dataset.meta.total_frames}); episodes are double-counted or missing."
)
new_stats = aggregate_episode_stats(
dataset, all_episode_stats, update_episode_stats=self.update_episode_stats
)
if new_stats is None:
raise RuntimeError("Aggregation produced no stats")
logging.info(f"Wrote stats for features: {list(new_stats.keys())} to {dataset.root}")
if self.push_to_hub:
logging.info(f"Pushing {self.repo_id} to hub")
dataset.push_to_hub()
def _mem_gb(mem: str) -> int:
"""Parse '4G' / '4GB' / '4' into an int number of GB for datatrove's mem_per_cpu_gb."""
s = str(mem).strip().lower().rstrip("b").rstrip("g")
return int(float(s))
def _make_executor(
pipeline,
logs_dir,
job_name,
slurm,
workers,
tasks,
time,
partition,
cpus,
mem,
qos=None,
env_command=None,
venv_path=None,
depends=None,
):
kwargs = {"pipeline": pipeline, "logging_dir": str(Path(logs_dir) / job_name)}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": tasks,
"workers": workers,
"time": time,
"partition": partition,
"cpus_per_task": cpus,
"mem_per_cpu_gb": _mem_gb(mem), # datatrove's native field (int GB)
"sbatch_args": {},
}
)
if qos:
kwargs["qos"] = qos # -> "#SBATCH --qos=<qos>" on every worker
if venv_path:
kwargs["venv_path"] = venv_path # datatrove sources this before the python step
if env_command:
kwargs["env_command"] = env_command # extra raw snippet before python (composes with venv_path)
if depends is not None:
kwargs["depends"] = depends # chains --dependency=afterok:<compute jobid>
return SlurmPipelineExecutor(**kwargs)
kwargs.update({"tasks": tasks, "workers": 1})
return LocalPipelineExecutor(**kwargs)
def _maybe_reference_copy(repo_id, root, new_root, download_videos):
"""Create the read-only-safe reference copy once, before submitting workers.
Loads metadata only (to resolve the source root and revision) instead of a full
``LeRobotDataset``, which would also memory-map the entire frame index just to read a
path. Fetches the source into the shared cache so the copy's symlinks point at real
files and workers don't each re-download, pulling videos only when the run needs them
(i.e. when image/video stats are being recomputed).
"""
if not new_root:
return
from huggingface_hub import snapshot_download
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
from lerobot.scripts.lerobot_edit_dataset import _reference_copy_dataset
from lerobot.utils.constants import HF_LEROBOT_HUB_CACHE
new_root_path = Path(new_root)
if new_root_path.exists():
return
meta = LeRobotDatasetMetadata(repo_id, root=Path(root) if root else None)
ignore_patterns = None if download_videos else "videos/"
if root:
snapshot_download(
repo_id,
repo_type="dataset",
revision=meta.revision,
local_dir=meta.root,
ignore_patterns=ignore_patterns,
)
src_root = Path(meta.root)
else:
src_root = Path(
snapshot_download(
repo_id,
repo_type="dataset",
revision=meta.revision,
cache_dir=HF_LEROBOT_HUB_CACHE,
ignore_patterns=ignore_patterns,
)
)
_reference_copy_dataset(src_root, new_root_path)
def _add_shared_args(p):
p.add_argument("--repo-id", type=str, required=True, help="Dataset identifier, e.g. 'user/dataset'.")
p.add_argument("--root", type=str, default=None, help="Source dataset root (defaults to the Hub cache).")
p.add_argument(
"--new-root",
type=str,
default=None,
help="Writable output root; a read-only-safe reference copy of --root. If omitted, stats "
"are written in place at --root.",
)
p.add_argument("--shard-dir", type=Path, default=Path("stats_shards"), help="Per-rank shard dir.")
p.add_argument("--logs-dir", type=Path, default=Path("logs"), help="datatrove logs dir.")
p.add_argument("--job-name", type=str, default=None, help="SLURM job name.")
p.add_argument("--slurm", type=int, default=1, help="1 = submit via SLURM; 0 = run locally.")
p.add_argument("--partition", type=str, default=None, help="SLURM partition, e.g. 'hopper-cpu'.")
p.add_argument("--qos", type=str, default=None, help="SLURM QoS, e.g. 'normal'. Passed to every worker.")
p.add_argument("--cpus-per-task", type=int, default=4, help="CPUs per SLURM task.")
p.add_argument("--mem-per-cpu", type=str, default="4G", help="Memory per CPU, e.g. '4G'.")
p.add_argument(
"--video-backend",
type=str,
default=None,
help="Video decoding backend (e.g. 'pyav', 'torchcodec'). Defaults to the dataset's default; "
"use 'pyav' if torchcodec fails to load locally.",
)
p.add_argument("--venv-path", type=str, default=None, help="venv activate script sourced on each worker.")
p.add_argument(
"--env-command",
type=str,
default=None,
help="Raw shell snippet injected into each worker's sbatch before the python step "
"(e.g. to export HF_LEROBOT_HOME). Runs in addition to --venv-path.",
)
def main():
parser = argparse.ArgumentParser(
description="PATCHED SLURM-distributed LeRobotDataset stats recomputation",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
cp = sub.add_parser("compute", help="Distribute per-episode stats across SLURM workers.")
_add_shared_args(cp)
cp.add_argument("--workers", type=int, default=50, help="Number of parallel SLURM tasks.")
cp.add_argument(
"--skip-image-video",
type=int,
default=1,
help="1 = numeric features only (fast); 0 = also recompute image/video stats (decodes frames).",
)
cp.add_argument(
"--chain-aggregate",
action="store_true",
help="After building compute, submit aggregate with an afterok dependency (single command).",
)
cp.add_argument("--push-to-hub", action="store_true", help="For the chained aggregate: push after done.")
cp.add_argument(
"--update-episode-stats",
action="store_true",
help="For the chained aggregate: also rewrite per-episode stats in the episodes parquet.",
)
ap = sub.add_parser("aggregate", help="Merge shards into meta/stats.json.")
_add_shared_args(ap)
ap.add_argument("--push-to-hub", action="store_true", help="Push the dataset after aggregation.")
ap.add_argument(
"--update-episode-stats",
action="store_true",
help="Also rewrite per-episode stats in the episodes parquet to match stats.json.",
)
ap.add_argument(
"--depends-job-id",
type=str,
default=None,
help="Optional SLURM job id; aggregate waits for it (afterok) before running.",
)
args = parser.parse_args()
slurm = args.slurm == 1
if args.command == "compute":
# The reference copy (if any) is created once on the submitting node so workers
# can all load --new-root without racing to build it. Videos are only fetched when
# image/video stats are being recomputed.
_maybe_reference_copy(
args.repo_id, args.root, args.new_root, download_videos=not bool(args.skip_image_video)
)
compute_exec = _make_executor(
pipeline=[
ComputeEpisodeStatsShards(
args.repo_id,
args.root,
args.new_root,
bool(args.skip_image_video),
str(args.shard_dir),
args.video_backend,
)
],
logs_dir=args.logs_dir,
job_name=args.job_name or "recompute_stats_compute",
slurm=slurm,
workers=args.workers,
tasks=args.workers,
time="24:00:00",
partition=args.partition,
cpus=args.cpus_per_task,
mem=args.mem_per_cpu,
qos=args.qos,
env_command=args.env_command,
venv_path=args.venv_path,
)
if args.chain_aggregate and slurm:
# Build aggregate depending on compute. datatrove launches the dependency
# (compute) first, then submits aggregate with --dependency=afterok:<jobid>.
aggregate_exec = _make_executor(
pipeline=[
AggregateEpisodeStats(
args.repo_id,
args.root,
args.new_root,
str(args.shard_dir),
args.push_to_hub,
args.video_backend,
args.update_episode_stats,
)
],
logs_dir=args.logs_dir,
job_name="recompute_stats_aggregate",
slurm=slurm,
workers=1,
tasks=1,
time="02:00:00",
partition=args.partition,
cpus=args.cpus_per_task,
mem=args.mem_per_cpu,
qos=args.qos,
env_command=args.env_command,
venv_path=args.venv_path,
depends=compute_exec,
)
aggregate_exec.run()
else:
compute_exec.run()
else:
aggregate_exec = _make_executor(
pipeline=[
AggregateEpisodeStats(
args.repo_id,
args.root,
args.new_root,
str(args.shard_dir),
args.push_to_hub,
args.video_backend,
args.update_episode_stats,
)
],
logs_dir=args.logs_dir,
job_name=args.job_name or "recompute_stats_aggregate",
slurm=slurm,
workers=1,
tasks=1,
time="02:00:00",
partition=args.partition,
cpus=args.cpus_per_task,
mem=args.mem_per_cpu,
qos=args.qos,
env_command=args.env_command,
venv_path=args.venv_path,
)
if args.depends_job_id is not None:
aggregate_exec.depends_job_id = args.depends_job_id
aggregate_exec.run()
if __name__ == "__main__":
main()
+1
View File
@@ -306,6 +306,7 @@ class RTCEvaluator:
# Configure RTC
rtc_config = RTCConfig(
enabled=rtc_enabled,
mode=self.cfg.rtc.mode,
execution_horizon=self.cfg.rtc.execution_horizon,
max_guidance_weight=self.cfg.rtc.max_guidance_weight,
prefix_attention_schedule=self.cfg.rtc.prefix_attention_schedule,
-6
View File
@@ -25,8 +25,6 @@ from .compute_stats import DEFAULT_QUANTILES, aggregate_stats, get_feature_stats
from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata
from .dataset_tools import (
add_features,
aggregate_episode_stats,
compute_dataset_episode_stats,
convert_image_to_video_dataset,
delete_episodes,
merge_datasets,
@@ -36,7 +34,6 @@ from .dataset_tools import (
reencode_dataset,
remove_feature,
split_dataset,
write_episode_stats,
)
from .factory import make_dataset, make_train_eval_datasets, resolve_delta_timestamps
from .image_writer import safe_stop_image_writer
@@ -81,10 +78,8 @@ __all__ = [
"detect_available_encoders_pyav",
"add_features",
"aggregate_datasets",
"aggregate_episode_stats",
"aggregate_pipeline_dataset_features",
"aggregate_stats",
"compute_dataset_episode_stats",
"convert_image_to_video_dataset",
"create_initial_features",
"compute_sampler_state",
@@ -104,6 +99,5 @@ __all__ = [
"resolve_delta_timestamps",
"safe_stop_image_writer",
"split_dataset",
"write_episode_stats",
"write_stats",
]
+93 -5
View File
@@ -18,8 +18,13 @@ from __future__ import annotations
import logging
import numpy as np
import torch
from lerobot.processor import RelativeActionsProcessorStep
from lerobot.processor import (
RelativeActionsProcessorStep,
relative_action_output_dim,
to_relative_actions,
)
from lerobot.utils.constants import ACTION, OBS_STATE
from .io_utils import load_image_as_numpy
@@ -660,17 +665,29 @@ def _compute_relative_chunk_batch(
all_states: np.ndarray,
chunk_size: int,
relative_mask: np.ndarray,
pose_representation: str = "componentwise",
se3_pose_groups: list[list[int]] | None = None,
) -> np.ndarray:
"""Vectorised relative-action computation for a batch of start indices.
Returns an ``(N * chunk_size, action_dim)`` float32 array.
Returns an ``(N * chunk_size, model_action_dim)`` float32 array.
"""
if len(start_indices) == 0:
return np.empty((0, all_actions.shape[1]), dtype=np.float32)
output_dim = relative_action_output_dim(all_actions.shape[1], pose_representation, se3_pose_groups)
return np.empty((0, output_dim), dtype=np.float32)
offsets = np.arange(chunk_size)
frame_idx = start_indices[:, None] + offsets[None, :]
chunks = all_actions[frame_idx].copy()
states = all_states[start_indices]
if pose_representation in {"se3", "se3_6d"}:
converted = to_relative_actions(
torch.from_numpy(chunks),
torch.from_numpy(states),
relative_mask.astype(bool).tolist(),
pose_representation=pose_representation,
se3_pose_groups=se3_pose_groups,
)
return converted.numpy().reshape(-1, converted.shape[-1])
mask_dim = len(relative_mask)
chunks[:, :, :mask_dim] -= states[:, None, :mask_dim] * relative_mask[None, None, :]
return chunks.reshape(-1, all_actions.shape[1])
@@ -682,6 +699,9 @@ def compute_relative_action_stats(
chunk_size: int,
exclude_joints: list[str] | None = None,
num_workers: int = 0,
state_from_action: bool = False,
pose_representation: str = "componentwise",
se3_pose_groups: list[list[int]] | None = None,
) -> dict[str, np.ndarray]:
"""Compute normalization statistics for relative actions over the full dataset.
@@ -700,6 +720,9 @@ def compute_relative_action_stats(
num_workers: Number of parallel threads for computation. Values 1
mean single-threaded. Numpy releases the GIL so threads give
real parallelism here.
state_from_action: Use the current absolute action as state. This is
intended for state-less pose datasets where each action row is the
synchronized measured robot pose.
Returns:
Statistics dict with keys "mean", "std", "min", "max", "q01", , "q99".
@@ -722,7 +745,7 @@ def compute_relative_action_stats(
logging.info("Loading action/state data for relative action stats...")
all_actions = np.array(hf_dataset[ACTION], dtype=np.float32)
all_states = np.array(hf_dataset[OBS_STATE], dtype=np.float32)
all_states = all_actions if state_from_action else np.array(hf_dataset[OBS_STATE], dtype=np.float32)
episode_indices = np.array(hf_dataset["episode_index"])
valid_starts = _get_valid_chunk_starts(episode_indices, chunk_size)
@@ -754,6 +777,8 @@ def compute_relative_action_stats(
all_states,
chunk_size,
relative_mask,
pose_representation,
se3_pose_groups,
)
for batch in batches
]
@@ -762,7 +787,15 @@ def compute_relative_action_stats(
else:
for batch in batches:
running_stats.update(
_compute_relative_chunk_batch(batch, all_actions, all_states, chunk_size, relative_mask)
_compute_relative_chunk_batch(
batch,
all_actions,
all_states,
chunk_size,
relative_mask,
pose_representation,
se3_pose_groups,
)
)
stats = running_stats.get_statistics()
@@ -777,3 +810,58 @@ def compute_relative_action_stats(
)
return stats
def compute_state_history_stats(
hf_dataset,
features: dict,
history_steps: int,
exclude_joints: list[str] | None = None,
relative: bool = False,
pose_representation: str = "componentwise",
se3_pose_groups: list[list[int]] | None = None,
) -> dict[str, np.ndarray]:
"""Compute stats for flattened state history synthesized from absolute actions.
History is left-padded with the first action of each episode, matching dataset
boundary padding. When ``relative`` is enabled, every history pose is expressed
relative to its newest pose while excluded dimensions remain absolute.
"""
if history_steps < 1:
raise ValueError("history_steps must be at least 1")
if exclude_joints is None:
exclude_joints = []
actions = np.asarray(hf_dataset[ACTION], dtype=np.float32)
episode_indices = np.asarray(hf_dataset["episode_index"])
sample_indices = np.arange(len(actions))
episode_starts = np.maximum.accumulate(
np.where(
np.concatenate(([True], episode_indices[1:] != episode_indices[:-1])),
sample_indices,
0,
)
)
offsets = np.arange(-(history_steps - 1), 1)
history_indices = np.maximum(sample_indices[:, None] + offsets[None, :], episode_starts[:, None])
history = actions[history_indices].copy()
if relative:
state_dim = actions.shape[-1]
names = features.get(ACTION, {}).get("names")
mask_step = RelativeActionsProcessorStep(
enabled=True,
exclude_joints=exclude_joints,
action_names=names,
)
mask = mask_step._build_mask(state_dim)
history = to_relative_actions(
torch.from_numpy(history),
torch.from_numpy(history[:, -1].copy()),
mask,
pose_representation=pose_representation,
se3_pose_groups=se3_pose_groups,
).numpy()
flattened = history.reshape(len(history), -1)
return get_feature_stats(flattened, axis=0, keepdims=False)
+89 -290
View File
@@ -33,13 +33,11 @@ from pathlib import Path
import datasets
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from tqdm import tqdm
from lerobot.configs import (
DEFAULT_DEPTH_UNIT,
DepthEncoderConfig,
RGBEncoderConfig,
VideoEncoderConfig,
@@ -53,15 +51,12 @@ from lerobot.utils.utils import flatten_dict
from .aggregate import aggregate_datasets
from .compute_stats import (
RunningQuantileStats,
aggregate_stats,
auto_downsample_height_width,
compute_episode_stats,
compute_relative_action_stats,
sample_indices,
compute_state_history_stats,
)
from .dataset_metadata import LeRobotDatasetMetadata
from .depth_utils import dequantize_depth
from .image_writer import write_image
from .io_utils import (
get_parquet_file_size_in_mb,
@@ -83,7 +78,6 @@ from .utils import (
update_chunk_file_indices,
)
from .video_utils import (
decode_video_frames,
encode_video_frames,
reencode_video,
)
@@ -1566,191 +1560,6 @@ def modify_tasks(
return dataset
def _load_episode_image_frames(
dataset: LeRobotDataset,
key: str,
ep_idx: int,
frame_offsets: list[int],
is_depth: bool,
) -> np.ndarray:
"""Load sampled frames of an image feature for one episode as a (N, C, H, W) array."""
ep = dataset.meta.episodes[ep_idx]
from_idx = ep["dataset_from_index"]
column = dataset.hf_dataset.with_format(None).select_columns(key)
frames = []
for offset in frame_offsets:
img = column[from_idx + offset][key]
if is_depth:
arr = np.array(img)
if arr.ndim == 2:
arr = arr[np.newaxis, ...]
else:
arr = np.transpose(np.array(img.convert("RGB"), dtype=np.uint8), (2, 0, 1))
frames.append(auto_downsample_height_width(arr))
return np.stack(frames)
def _load_episode_video_frames(
dataset: LeRobotDataset,
key: str,
ep_idx: int,
frame_offsets: list[int],
is_depth: bool,
) -> np.ndarray:
"""Load sampled frames of a video feature for one episode as a (N, C, H, W) array."""
ep = dataset.meta.episodes[ep_idx]
video_path = dataset.root / dataset.meta.get_video_file_path(ep_idx, key)
from_timestamp = ep[f"videos/{key}/from_timestamp"]
timestamps = [from_timestamp + offset / dataset.meta.fps for offset in frame_offsets]
frames = decode_video_frames(
video_path,
timestamps,
dataset.tolerance_s,
backend=dataset._video_backend,
return_uint8=not is_depth,
is_depth=is_depth,
)
if is_depth:
# ``decode_video_frames`` returns raw 12-bit codec values; dequantize back to
# the recorded depth unit so stats match record-time stats (which are stored in
# ``info.depth_unit`` and only rescaled to the output unit on read).
info = dataset.meta.features[key].get("info") or {}
depth_encoder = DepthEncoderConfig.from_video_info(info)
frames = dequantize_depth(
frames,
depth_min=depth_encoder.depth_min,
depth_max=depth_encoder.depth_max,
shift=depth_encoder.shift,
use_log=depth_encoder.use_log,
output_unit=info.get("depth_unit") or DEFAULT_DEPTH_UNIT,
)
return np.stack([auto_downsample_height_width(frame) for frame in frames.numpy()])
def _compute_visual_episode_stats(
dataset: LeRobotDataset,
ep_idx: int,
visual_keys: list[str],
frame_batch_size: int = 32,
) -> dict:
"""Compute per-episode statistics for image/video features by sampling frames.
Mirrors the image/video branch of :func:`compute_episode_stats`: per-channel stats
are computed on downsampled sampled frames, then RGB stats are rescaled to [0, 1]
(depth maps keep their native units).
Frames are decoded and accumulated into a :class:`RunningQuantileStats` in batches of
``frame_batch_size`` rather than materialising every sampled frame at once. Peak memory
is bounded by one batch (``frame_batch_size x C x H x W``) regardless of episode length,
which keeps long, high-resolution episodes from exhausting memory.
"""
ep_length = dataset.meta.episodes[ep_idx]["length"]
frame_offsets = sample_indices(ep_length)
ep_stats = {}
for key in visual_keys:
is_depth = key in dataset.meta.depth_keys
is_video = dataset.meta.features[key]["dtype"] == "video"
running = RunningQuantileStats()
for start in range(0, len(frame_offsets), frame_batch_size):
batch_offsets = frame_offsets[start : start + frame_batch_size]
if is_video:
frames = _load_episode_video_frames(dataset, key, ep_idx, batch_offsets, is_depth)
else:
frames = _load_episode_image_frames(dataset, key, ep_idx, batch_offsets, is_depth)
# (N, C, H, W) -> (N * H * W, C) so stats are accumulated per channel.
running.update(np.moveaxis(frames, 1, -1).reshape(-1, frames.shape[1]))
stats = running.get_statistics()
normalization_factor = 1.0 if is_depth else 255.0
num_channels = stats["mean"].shape[0]
# ``count`` follows the per-frame convention of ``get_feature_stats`` (number of
# sampled frames), not the per-pixel count tracked internally by RunningQuantileStats.
ep_stats[key] = {
k: np.array([len(frame_offsets)])
if k == "count"
else v.reshape(num_channels, 1, 1) / normalization_factor
for k, v in stats.items()
}
return ep_stats
def compute_dataset_episode_stats(
dataset: LeRobotDataset,
episode_indices: list[int] | None = None,
skip_image_video: bool = True,
drop_keys: list[str] | None = None,
) -> dict[int, dict]:
"""Compute per-episode statistics for a subset of episodes.
This is the shardable unit of work behind :func:`recompute_stats`: distribute
``episode_indices`` across workers (e.g. ``list(range(n))[rank::world_size]``),
then combine the results with :func:`aggregate_episode_stats`.
Args:
dataset: The LeRobotDataset to compute stats for.
episode_indices: Episodes to process. When ``None``, all episodes are processed.
skip_image_video: If True (default), only numeric features are computed. If False,
image/video stats are also computed by sampling and decoding frames.
drop_keys: Feature keys to exclude (e.g. ``action`` when it is computed separately
in relative-action space).
Returns:
A mapping of episode index to its per-episode stat dict. Keeping the episode index
(rather than a bare list) lets callers write the stats back to the correct episode
row, and survives sharding since shards can be merged by key.
"""
features = dataset.meta.features
meta_keys = {"index", "episode_index", "task_index", "frame_index", "timestamp"}
drop = set(drop_keys or [])
features_to_compute = {
k: v
for k, v in features.items()
if v["dtype"] != "string"
and k not in meta_keys
and k not in drop
and (not skip_image_video or v["dtype"] not in ["image", "video"])
}
numeric_keys = [k for k, v in features_to_compute.items() if v["dtype"] not in ["image", "video"]]
visual_keys = [k for k, v in features_to_compute.items() if v["dtype"] in ["image", "video"]]
if dataset.meta.episodes is None:
dataset.meta.episodes = load_episodes(dataset.meta.root)
if episode_indices is None:
episode_indices = list(range(dataset.meta.total_episodes))
# Group requested episodes by their data parquet file so each file is read once.
file_to_episodes: dict[Path, list[int]] = {}
for ep_idx in episode_indices:
file_to_episodes.setdefault(dataset.meta.get_data_file_path(ep_idx), []).append(ep_idx)
all_episode_stats = {}
for src_path, eps in tqdm(sorted(file_to_episodes.items()), desc="Computing stats from data files"):
df = pd.read_parquet(dataset.root / src_path) if numeric_keys else None
for ep_idx in sorted(eps):
episode_data = {}
if numeric_keys:
ep_df = df[df["episode_index"] == ep_idx]
for key in numeric_keys:
if key in ep_df.columns:
values = ep_df[key].values
episode_data[key] = (
np.stack(values) if hasattr(values[0], "__len__") else np.array(values)
)
ep_stats = compute_episode_stats(episode_data, features_to_compute)
if visual_keys:
ep_stats.update(_compute_visual_episode_stats(dataset, int(ep_idx), visual_keys))
all_episode_stats[int(ep_idx)] = ep_stats
return all_episode_stats
def recompute_stats(
dataset: LeRobotDataset,
skip_image_video: bool = True,
@@ -1758,21 +1567,19 @@ def recompute_stats(
relative_exclude_joints: list[str] | None = None,
chunk_size: int = 50,
num_workers: int = 0,
update_episode_stats: bool = False,
state_from_action: bool = False,
state_history_steps: int = 1,
relative_state_history: bool = False,
relative_state_exclude_joints: list[str] | None = None,
relative_pose_representation: str = "componentwise",
relative_se3_pose_groups: list[list[int]] | None = None,
) -> LeRobotDataset:
"""Recompute stats.json from scratch by iterating all episodes.
Args:
dataset: The LeRobotDataset to recompute stats for.
skip_image_video: If True (default), only recompute stats for numeric features
(action, state, etc.) and keep existing image/video stats unchanged. If False,
image/video stats are also recomputed by sampling and decoding frames from each
episode (this reads the image/video files, unlike the numeric-only path).
update_episode_stats: If True, also rewrite the per-episode ``stats/*`` columns in the
episodes parquet files so they stay consistent with the aggregated ``stats.json``.
Defaults to False (only ``stats.json`` is rewritten). Requires a writable
``dataset.root``. Note that relative-action stats are aggregate-only and are not
written per-episode.
(action, state, etc.) and keep existing image/video stats unchanged.
relative_action: If True, compute action stats in relative space by
iterating all valid action chunks and subtracting the current state.
This matches the normalization distribution the model sees during
@@ -1783,18 +1590,54 @@ def recompute_stats(
``policy.chunk_size``. Only used when ``relative_action=True``.
num_workers: Number of parallel threads for relative action stats computation.
Values 1 mean single-threaded. Only used when ``relative_action=True``.
state_from_action: Use absolute action rows as synthetic state while
computing relative-action stats, and write their absolute statistics
under ``observation.state``.
state_history_steps: Number of consecutive synthesized state samples.
relative_state_history: Express state history relative to its newest pose.
relative_state_exclude_joints: State dimensions to retain as absolute.
relative_pose_representation: ``componentwise`` for legacy subtraction,
``se3`` for composition with an axis-angle output, or ``se3_6d`` for
composition with a continuous two-column rotation output.
relative_se3_pose_groups: Six-index xyz+rotation-vector pose groups.
Returns:
The same dataset with updated stats.
"""
features = dataset.meta.features
meta_keys = {"index", "episode_index", "task_index", "frame_index", "timestamp"}
numeric_features = {
k: v
for k, v in features.items()
if v["dtype"] not in ["image", "video", "string"] and k not in meta_keys
}
if skip_image_video:
features_to_compute = numeric_features
else:
features_to_compute = {
k: v for k, v in features.items() if v["dtype"] != "string" and k not in meta_keys
}
# When relative_action is enabled, compute action stats via chunk-based sampling
# (matching what the model sees during training) and skip action in the
# per-episode pass below.
relative_action_stats = None
drop_keys = None
if relative_action and ACTION in features and OBS_STATE in features:
synthetic_state_stats = None
if state_from_action:
if ACTION not in features:
raise ValueError("state_from_action requires an action feature")
synthetic_state_stats = compute_state_history_stats(
dataset.hf_dataset,
features,
history_steps=state_history_steps,
exclude_joints=relative_state_exclude_joints,
relative=relative_state_history,
pose_representation=relative_pose_representation,
se3_pose_groups=relative_se3_pose_groups,
)
if relative_action and ACTION in features and (OBS_STATE in features or state_from_action):
if relative_exclude_joints is None:
relative_exclude_joints = ["gripper"]
relative_action_stats = compute_relative_action_stats(
@@ -1803,106 +1646,62 @@ def recompute_stats(
chunk_size=chunk_size,
exclude_joints=relative_exclude_joints,
num_workers=num_workers,
state_from_action=state_from_action,
pose_representation=relative_pose_representation,
se3_pose_groups=relative_se3_pose_groups,
)
drop_keys = [ACTION]
features_to_compute.pop(ACTION, None)
all_episode_stats = compute_dataset_episode_stats(
dataset, skip_image_video=skip_image_video, drop_keys=drop_keys
)
logging.info(f"Recomputing stats for features: {list(features_to_compute.keys())}")
new_stats = aggregate_episode_stats(
dataset,
all_episode_stats,
extra_stats={ACTION: relative_action_stats} if relative_action_stats else None,
update_episode_stats=update_episode_stats,
)
if new_stats is None:
data_dir = dataset.root / DATA_DIR
parquet_files = sorted(data_dir.glob("*/*.parquet"))
if not parquet_files:
raise ValueError(f"No parquet files found in {data_dir}")
all_episode_stats = []
# TODO: enable image and video stats re-computation
numeric_keys = [k for k, v in features_to_compute.items() if v["dtype"] not in ["image", "video"]]
for parquet_path in tqdm(parquet_files, desc="Computing stats from data files"):
df = pd.read_parquet(parquet_path)
for ep_idx in sorted(df["episode_index"].unique()):
ep_df = df[df["episode_index"] == ep_idx]
episode_data = {}
for key in numeric_keys:
if key in ep_df.columns:
values = ep_df[key].values
if hasattr(values[0], "__len__"):
episode_data[key] = np.stack(values)
else:
episode_data[key] = np.array(values)
ep_stats = compute_episode_stats(episode_data, features_to_compute)
all_episode_stats.append(ep_stats)
if features_to_compute and not all_episode_stats:
logging.warning("No episode stats computed")
else:
logging.info("Stats recomputed successfully")
return dataset
return dataset
new_stats = aggregate_stats(all_episode_stats) if all_episode_stats else {}
def write_episode_stats(dataset: LeRobotDataset, episode_stats: dict[int, dict]) -> None:
"""Overwrite the per-episode ``stats/*`` columns in the episodes parquet files in place.
if relative_action_stats is not None:
new_stats[ACTION] = relative_action_stats
if synthetic_state_stats is not None:
new_stats[OBS_STATE] = synthetic_state_stats
Only the features present in ``episode_stats[ep_idx]`` are rewritten; stats columns for
features that were not recomputed are left untouched. Every other episode column (tasks,
length, chunk/file indices, frame ranges, ) is preserved. ``dataset.root`` must be
writable (e.g. the reference copy created for read-only sources).
"""
if not episode_stats:
return
meta = dataset.meta
if meta.episodes is None:
meta.episodes = load_episodes(meta.root)
# Group episodes by the parquet file that holds them so each file is rewritten once.
file_to_episodes: dict[tuple[int, int], list[int]] = {}
for ep_idx in episode_stats:
ep = meta.episodes[ep_idx]
key = (ep["meta/episodes/chunk_index"], ep["meta/episodes/file_index"])
file_to_episodes.setdefault(key, []).append(ep_idx)
for (chunk_idx, file_idx), eps in file_to_episodes.items():
path = meta.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx)
table = pq.read_table(path)
rows = table.to_pylist()
row_by_ep = {row["episode_index"]: row for row in rows}
for ep_idx in eps:
row = row_by_ep[ep_idx]
for feature, feature_stats in episode_stats[ep_idx].items():
for stat_name, value in feature_stats.items():
col = f"stats/{feature}/{stat_name}"
if col in row:
row[col] = np.asarray(value).tolist()
# Reuse the source schema so the rewritten stats keep the exact on-disk types.
new_table = pa.Table.from_pylist(rows, schema=table.schema)
pq.write_table(new_table, path, compression="snappy", use_dictionary=True)
def aggregate_episode_stats(
dataset: LeRobotDataset,
episode_stats: dict[int, dict],
extra_stats: dict | None = None,
update_episode_stats: bool = False,
) -> dict | None:
"""Aggregate per-episode stats, merge with existing stats, and write ``stats.json``.
Companion to :func:`compute_dataset_episode_stats` for the distributed workflow: pass the
merged ``{episode_index: stats}`` mapping of every worker's per-episode stats. ``extra_stats``
lets callers inject feature stats computed outside the per-episode pass (e.g. relative-action
stats).
Args:
dataset: The dataset whose ``meta/stats.json`` (and optionally episode stats) is updated.
episode_stats: Mapping of episode index to its per-episode stat dict.
extra_stats: Feature stats to inject into the aggregate (not written per-episode).
update_episode_stats: If True, also rewrite the per-episode ``stats/*`` columns in the
episodes parquet files via :func:`write_episode_stats`.
Returns the written stats dict, or ``None`` if there was nothing to aggregate.
"""
if not episode_stats and not extra_stats:
return None
new_stats = aggregate_stats(list(episode_stats.values())) if episode_stats else {}
if extra_stats:
new_stats.update(extra_stats)
# Merge: keep existing stats for features we didn't recompute.
# Merge: keep existing stats for features we didn't recompute
if dataset.meta.stats:
for key, value in dataset.meta.stats.items():
new_stats.setdefault(key, value)
if key not in new_stats:
new_stats[key] = value
write_stats(new_stats, dataset.root)
dataset.meta.stats = new_stats
if update_episode_stats:
write_episode_stats(dataset, episode_stats)
return new_stats
logging.info("Stats recomputed successfully")
return dataset
def convert_image_to_video_dataset(
@@ -55,9 +55,25 @@ class PI05Config(PreTrainedConfig):
relative_exclude_joints: list[str] = field(default_factory=lambda: ["gripper"])
# Populated at runtime from dataset metadata by make_policy.
action_feature_names: list[str] | None = None
# ``se3`` uses inv(T_current) @ T_target for each xyz+rotation-vector pose group.
# ``se3_6d`` uses the same composition and expands each relative rotation
# vector to the continuous first-two-row 6-D rotation representation.
# ``componentwise`` preserves the legacy action - state behavior.
relative_pose_representation: str = "componentwise"
relative_se3_pose_groups: list[list[int]] = field(default_factory=lambda: [list(range(6))])
# Build proprioception from absolute action samples when the dataset has no
# observation.state. With history_steps=2, training samples request t-1 as
# well as the normal t..t+chunk_size-1 action targets.
state_from_action: bool = False
proprioception_history_steps: int = 1
use_relative_state_history: bool = False
relative_state_exclude_joints: list[str] = field(default_factory=lambda: ["gripper"])
# Real-Time Chunking (RTC) configuration
rtc_config: RTCConfig | None = None
# Maximum clean action-prefix length sampled during training. Zero disables trained RTC.
rtc_training_max_delay: int = 0
image_resolution: tuple[int, int] = (
DEFAULT_IMAGE_SIZE,
@@ -111,6 +127,11 @@ class PI05Config(PreTrainedConfig):
raise ValueError(
f"n_action_steps ({self.n_action_steps}) cannot be greater than chunk_size ({self.chunk_size})"
)
if not 0 <= self.rtc_training_max_delay < self.chunk_size:
raise ValueError(
"rtc_training_max_delay must satisfy "
f"0 <= delay < chunk_size ({self.chunk_size}), got {self.rtc_training_max_delay}"
)
if self.paligemma_variant not in ["gemma_300m", "gemma_2b"]:
raise ValueError(f"Invalid paligemma_variant: {self.paligemma_variant}")
@@ -121,6 +142,25 @@ class PI05Config(PreTrainedConfig):
if self.dtype not in ["bfloat16", "float32"]:
raise ValueError(f"Invalid dtype: {self.dtype}")
if self.proprioception_history_steps < 1:
raise ValueError("proprioception_history_steps must be at least 1")
if self.relative_pose_representation not in {"componentwise", "se3", "se3_6d"}:
raise ValueError(
"relative_pose_representation must be 'componentwise', 'se3', or 'se3_6d', got "
f"{self.relative_pose_representation!r}"
)
for group in self.relative_se3_pose_groups:
if len(group) != 6 or len(set(group)) != 6 or any(index < 0 for index in group):
raise ValueError(f"Invalid six-index SE(3) pose group: {group}")
if self.relative_pose_representation == "se3_6d" and group != list(range(group[0], group[0] + 6)):
raise ValueError("se3_6d pose groups must contain six contiguous ascending indices")
if self.relative_pose_representation in {"se3", "se3_6d"} and not self.relative_se3_pose_groups:
raise ValueError(
f"relative_pose_representation={self.relative_pose_representation!r} "
"requires relative_se3_pose_groups"
)
def validate_features(self) -> None:
"""Validate and set up input/output features."""
for i in range(self.empty_cameras):
@@ -131,19 +171,54 @@ class PI05Config(PreTrainedConfig):
)
self.input_features[key] = empty_camera
if OBS_STATE not in self.input_features:
state_feature = PolicyFeature(
type=FeatureType.STATE,
shape=(self.max_state_dim,), # Padded to max_state_dim
)
self.input_features[OBS_STATE] = state_feature
if ACTION not in self.output_features:
action_feature = PolicyFeature(
type=FeatureType.ACTION,
shape=(self.max_action_dim,), # Padded to max_action_dim
)
self.output_features[ACTION] = action_feature
elif self.relative_pose_representation == "se3_6d":
action_feature = self.output_features[ACTION]
source_dim = (
len(self.action_feature_names)
if self.action_feature_names is not None
else action_feature.shape[-1]
)
model_dim = source_dim + 3 * len(self.relative_se3_pose_groups)
if action_feature.shape[-1] == source_dim:
self.output_features[ACTION] = PolicyFeature(
type=action_feature.type,
shape=(model_dim,),
)
elif action_feature.shape[-1] != model_dim:
raise ValueError(
"se3_6d action feature has incompatible width: "
f"source={source_dim}, expected model width={model_dim}, "
f"got={action_feature.shape[-1]}"
)
if model_dim > self.max_action_dim:
raise ValueError(
f"se3_6d action width {model_dim} exceeds max_action_dim={self.max_action_dim}"
)
if OBS_STATE not in self.input_features:
state_shape = (self.max_state_dim,)
if self.state_from_action and ACTION in self.output_features:
state_shape = self.output_features[ACTION].shape
state_feature = PolicyFeature(
type=FeatureType.STATE,
shape=state_shape,
)
self.input_features[OBS_STATE] = state_feature
state_dim = self.input_features[OBS_STATE].shape[-1]
history_state_dim = state_dim * self.proprioception_history_steps
if history_state_dim > self.max_state_dim:
raise ValueError(
"Flattened proprioception history exceeds max_state_dim: "
f"{state_dim} * {self.proprioception_history_steps} = {history_state_dim} > "
f"{self.max_state_dim}"
)
def get_optimizer_preset(self) -> AdamWConfig:
return AdamWConfig(
@@ -168,7 +243,8 @@ class PI05Config(PreTrainedConfig):
@property
def action_delta_indices(self) -> list:
return list(range(self.chunk_size))
history_prefix = self.proprioception_history_steps - 1 if self.state_from_action else 0
return list(range(-history_prefix, self.chunk_size))
@property
def reward_delta_indices(self) -> None:
+169 -24
View File
@@ -66,6 +66,107 @@ class ActionSelectKwargs(TypedDict, total=False):
execution_horizon: int | None
def _prepare_trained_rtc_prefix(
x_t: Tensor,
prev_chunk_left_over: Tensor | None,
inference_delay: int,
training_max_delay: int,
) -> tuple[Tensor | None, Tensor | None]:
"""Pad and validate a hard prefix for training-time RTC inference."""
if prev_chunk_left_over is None or inference_delay <= 0:
return None, None
if training_max_delay <= 0:
raise ValueError(
"RTC mode='trained' requires a checkpoint trained with policy.rtc_training_max_delay > 0."
)
if inference_delay > training_max_delay:
raise ValueError(
f"Measured RTC inference delay ({inference_delay}) exceeds the checkpoint's "
f"rtc_training_max_delay ({training_max_delay})."
)
if inference_delay >= x_t.shape[1]:
raise ValueError(
f"RTC inference delay ({inference_delay}) must be smaller than chunk_size ({x_t.shape[1]})."
)
previous = prev_chunk_left_over.to(device=x_t.device, dtype=x_t.dtype)
if not torch.isfinite(previous).all():
raise ValueError("RTC prefix contains NaN or Inf values.")
if previous.ndim == 2:
previous = previous.unsqueeze(0)
if previous.ndim != 3:
raise ValueError(f"Expected RTC prefix shape (B, T, A), got {tuple(previous.shape)}")
if previous.shape[0] == 1 and x_t.shape[0] > 1:
previous = previous.expand(x_t.shape[0], -1, -1)
if previous.shape[0] != x_t.shape[0]:
raise ValueError(
f"RTC prefix batch size ({previous.shape[0]}) does not match policy batch ({x_t.shape[0]})."
)
if previous.shape[1] < inference_delay:
raise ValueError(f"RTC prefix has {previous.shape[1]} steps, but inference_delay={inference_delay}.")
if previous.shape[2] > x_t.shape[2]:
raise ValueError(
f"RTC prefix action dimension ({previous.shape[2]}) exceeds model dimension ({x_t.shape[2]})."
)
padded_prefix = torch.zeros_like(x_t)
padded_prefix[:, :inference_delay, : previous.shape[2]] = previous[:, :inference_delay]
prefix_mask = torch.arange(x_t.shape[1], device=x_t.device) < inference_delay
prefix_mask = prefix_mask[None, :, None].expand(x_t.shape[0], -1, x_t.shape[2])
return padded_prefix, prefix_mask
def _sample_training_rtc_prefix_mask(
batch_size: int,
action_horizon: int,
max_delay: int,
device: torch.device,
) -> Tensor | None:
"""Sample a clean action-prefix length independently for each training example."""
if max_delay <= 0:
return None
delays = torch.randint(0, max_delay + 1, (batch_size,), device=device)
positions = torch.arange(action_horizon, device=device)
return positions.unsqueeze(0) < delays.unsqueeze(1)
def _build_flow_matching_inputs(
actions: Tensor,
noise: Tensor,
time: Tensor,
prefix_mask: Tensor | None,
) -> tuple[Tensor, Tensor]:
"""Keep the sampled RTC prefix clean while noising the remaining action chunk."""
if prefix_mask is None:
model_time = time
expanded_time = time[:, None, None]
else:
model_time = time[:, None].expand_as(prefix_mask)
model_time = torch.where(prefix_mask, torch.zeros_like(model_time), model_time)
expanded_time = model_time.unsqueeze(-1)
x_t = expanded_time * noise + (1 - expanded_time) * actions
return x_t, model_time
def _reduce_training_rtc_loss(
losses: Tensor,
prefix_mask: Tensor | None,
reduction: str,
) -> Tensor:
"""Average flow loss over predicted postfix actions, excluding the clean RTC prefix."""
if reduction not in {"mean", "none"}:
raise ValueError(f"Unsupported loss reduction: {reduction!r}")
if prefix_mask is None:
return losses.mean() if reduction == "mean" else losses.mean(dim=(1, 2))
postfix_mask = (~prefix_mask).unsqueeze(-1).expand_as(losses)
if reduction == "none":
numerator = (losses * postfix_mask).sum(dim=(1, 2))
denominator = postfix_mask.sum(dim=(1, 2))
return numerator / denominator.clamp(min=1)
return (losses * postfix_mask).sum() / postfix_mask.sum().clamp(min=1)
def get_safe_dtype(target_dtype, device_type):
"""Get a safe dtype for the given device type."""
if device_type == "mps" and target_dtype == torch.float64:
@@ -82,21 +183,20 @@ def get_safe_dtype(target_dtype, device_type):
def create_sinusoidal_pos_embedding( # see openpi `create_sinusoidal_pos_embedding` (exact copy)
time: torch.Tensor, dimension: int, min_period: float, max_period: float, device="cpu"
) -> Tensor:
"""Computes sine-cosine positional embedding vectors for scalar positions."""
"""Compute sine-cosine embeddings for scalar or per-action positions."""
if dimension % 2 != 0:
raise ValueError(f"dimension ({dimension}) must be divisible by 2")
if time.ndim != 1:
raise ValueError("The time tensor is expected to be of shape `(batch_size, )`.")
if time.ndim not in (1, 2):
raise ValueError("The time tensor must have shape (batch_size,) or (batch_size, action_horizon).")
dtype = get_safe_dtype(torch.float64, device.type)
fraction = torch.linspace(0.0, 1.0, dimension // 2, dtype=dtype, device=device)
period = min_period * (max_period / min_period) ** fraction
# Compute the outer product
scaling_factor = 1.0 / period * 2 * math.pi
sin_input = scaling_factor[None, :] * time[:, None]
return torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=1)
sin_input = time[..., None] * scaling_factor
return torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=-1)
def sample_beta(alpha, beta, bsize, device): # see openpi `sample_beta` (exact copy)
@@ -739,14 +839,23 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
return embs, pad_masks, att_masks, adarms_cond
def forward(self, images, img_masks, tokens, masks, actions, noise, time) -> Tensor:
def forward(
self,
images,
img_masks,
tokens,
masks,
actions,
noise,
time,
prefix_mask: Tensor | None = None,
) -> Tensor:
"""Do a full training forward pass and compute the loss."""
time_expanded = time[:, None, None]
x_t = time_expanded * noise + (1 - time_expanded) * actions
x_t, model_time = _build_flow_matching_inputs(actions, noise, time, prefix_mask)
u_t = noise - actions
prefix_embs, prefix_pad_masks, prefix_att_masks = self.embed_prefix(images, img_masks, tokens, masks)
suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(x_t, time)
suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(x_t, model_time)
if (
self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype
@@ -833,11 +942,35 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
dt = -1.0 / num_steps
x_t = noise
rtc_mode = "guided"
trained_prefix = trained_prefix_mask = None
if self._rtc_enabled():
rtc_mode = self.rtc_processor.rtc_config.mode
if rtc_mode == "trained":
training_max_delay = int(getattr(self.config, "rtc_training_max_delay", 0))
if training_max_delay <= 0:
raise ValueError(
"RTC mode='trained' requires a checkpoint trained with "
"policy.rtc_training_max_delay > 0."
)
trained_prefix, trained_prefix_mask = _prepare_trained_rtc_prefix(
x_t,
kwargs.get("prev_chunk_left_over"),
int(kwargs.get("inference_delay") or 0),
training_max_delay,
)
for step in range(num_steps):
time = 1.0 + step * dt
time_tensor = torch.tensor(time, dtype=torch.float32, device=device).expand(bsize)
def denoise_step_partial_call(input_x_t, current_timestep=time_tensor):
denoise_timestep = time_tensor
if trained_prefix is not None:
x_t = torch.where(trained_prefix_mask, trained_prefix, x_t)
denoise_timestep = time_tensor[:, None].expand(bsize, x_t.shape[1]).clone()
denoise_timestep[trained_prefix_mask[..., 0]] = 0.0
def denoise_step_partial_call(input_x_t, current_timestep=denoise_timestep):
return self.denoise_step(
prefix_pad_masks=prefix_pad_masks,
past_key_values=past_key_values,
@@ -845,7 +978,7 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
timestep=current_timestep,
)
if self._rtc_enabled():
if self._rtc_enabled() and rtc_mode == "guided":
inference_delay = kwargs.get("inference_delay")
prev_chunk_left_over = kwargs.get("prev_chunk_left_over")
execution_horizon = kwargs.get("execution_horizon")
@@ -862,6 +995,8 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
v_t = denoise_step_partial_call(x_t)
x_t = x_t + dt * v_t
if trained_prefix is not None:
x_t = torch.where(trained_prefix_mask, trained_prefix, x_t)
if self.rtc_processor is not None and self.rtc_processor.is_debug_enabled():
self.rtc_processor.track(time=time, x_t=x_t, v_t=v_t)
@@ -1137,7 +1272,10 @@ class PI05Policy(PreTrainedPolicy):
# Create processor if config provided
# If RTC is not enabled - we can still track the denoising data
if self.config.rtc_config is not None:
self.rtc_processor = RTCProcessor(self.config.rtc_config)
self.rtc_processor = RTCProcessor(
self.config.rtc_config,
trained_mode_supported=int(getattr(self.config, "rtc_training_max_delay", 0)) > 0,
)
model_value = getattr(self, "model", None)
if model_value is not None:
@@ -1269,28 +1407,35 @@ class PI05Policy(PreTrainedPolicy):
noise = self.model.sample_noise(actions.shape, actions.device)
time = self.model.sample_time(actions.shape[0], actions.device)
prefix_mask = _sample_training_rtc_prefix_mask(
actions.shape[0],
actions.shape[1],
self.config.rtc_training_max_delay,
actions.device,
)
# Compute loss (no separate state needed for PI05)
losses = self.model.forward(images, img_masks, tokens, masks, actions, noise, time)
losses = self.model.forward(images, img_masks, tokens, masks, actions, noise, time, prefix_mask)
# Truncate losses to actual action dimensions
original_action_dim = self.config.output_features[ACTION].shape[0]
losses = losses[:, :, :original_action_dim]
loss_dict = {
"loss_per_dim": losses.mean(dim=[0, 1]).detach().cpu().numpy().tolist(),
}
if prefix_mask is None:
loss_per_dim = losses.mean(dim=(0, 1))
else:
postfix_mask = (~prefix_mask).unsqueeze(-1).expand_as(losses)
loss_per_dim = (losses * postfix_mask).sum(dim=(0, 1)) / postfix_mask.sum(dim=(0, 1)).clamp(min=1)
loss_dict = {"loss_per_dim": loss_per_dim.detach().cpu().numpy().tolist()}
if reduction == "none":
# Return per-sample losses (B,) by averaging over time and action dims
per_sample_loss = losses.mean(dim=(1, 2))
per_sample_loss = _reduce_training_rtc_loss(losses, prefix_mask, reduction="none")
loss_dict["loss"] = per_sample_loss.mean().item()
return per_sample_loss, loss_dict
else:
# Default: return scalar mean loss
loss = losses.mean()
loss_dict["loss"] = loss.item()
return loss, loss_dict
loss = _reduce_training_rtc_loss(losses, prefix_mask, reduction="mean")
loss_dict["loss"] = loss.item()
return loss, loss_dict
def _get_default_peft_targets(self) -> dict[str, any]:
"""Return default PEFT target modules for PI0.5 fine-tuning."""
+176 -1
View File
@@ -15,7 +15,7 @@
# limitations under the License.
from copy import deepcopy
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import numpy as np
@@ -36,6 +36,8 @@ from lerobot.processor import (
TokenizerProcessorStep,
UnnormalizerProcessorStep,
policy_action_to_transition,
relative_action_output_dim,
to_relative_actions,
transition_to_policy_action,
)
from lerobot.types import EnvTransition, TransitionKey
@@ -48,6 +50,164 @@ from lerobot.utils.constants import (
from .configuration_pi05 import PI05Config
@ProcessorStepRegistry.register(name="pi05_state_from_action_processor_step")
@dataclass
class Pi05StateFromActionProcessorStep(ProcessorStep):
"""Synthesize proprioception from absolute actions in state-less datasets.
The dataset loader supplies ``history_steps - 1`` actions before the normal
target chunk. Those leading samples and action(t) become state history; only
the leading samples are then removed from the action targets.
"""
enabled: bool = False
history_steps: int = 1
_inference_history: torch.Tensor | None = field(default=None, init=False, repr=False)
def __call__(self, transition: EnvTransition) -> EnvTransition:
if not self.enabled:
return transition
observation = transition.get(TransitionKey.OBSERVATION, {})
observed_state = observation.get(OBS_STATE)
if observed_state is not None:
# At inference the robot normally provides only the current state and
# there is no action target. Build a rolling history in the processor.
if transition.get(TransitionKey.ACTION) is None and observed_state.ndim == 2:
if self._inference_history is None:
self._inference_history = observed_state.unsqueeze(1).repeat(1, self.history_steps, 1)
else:
self._inference_history = torch.cat(
[self._inference_history[:, 1:], observed_state.unsqueeze(1)], dim=1
)
new_transition = transition.copy()
new_observation = dict(observation)
new_observation[OBS_STATE] = self._inference_history.clone()
new_transition[TransitionKey.OBSERVATION] = new_observation
return new_transition
return transition
action = transition.get(TransitionKey.ACTION)
if action is None:
raise ValueError("Cannot synthesize PI0.5 state without action")
if action.ndim != 3:
raise ValueError(f"Expected batched action chunks with shape (B, T, D), got {action.shape}")
if action.shape[1] < self.history_steps:
raise ValueError(
f"Action chunk has {action.shape[1]} steps, fewer than history_steps={self.history_steps}"
)
new_transition = transition.copy()
new_observation = dict(observation)
state = action[:, : self.history_steps].clone()
if self.history_steps == 1:
state = state[:, 0]
new_observation[OBS_STATE] = state
new_transition[TransitionKey.OBSERVATION] = new_observation
new_transition[TransitionKey.ACTION] = action[:, self.history_steps - 1 :]
return new_transition
def get_config(self) -> dict[str, Any]:
return {"enabled": self.enabled, "history_steps": self.history_steps}
def reset(self) -> None:
self._inference_history = None
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
return features
@ProcessorStepRegistry.register(name="pi05_flatten_state_history_processor_step")
@dataclass
class Pi05FlattenStateHistoryProcessorStep(ProcessorStep):
"""Optionally relativize raw state history, then flatten it for PI0.5."""
history_steps: int = 1
max_state_dim: int = 32
relative: bool = False
exclude_joints: list[str] = field(default_factory=list)
state_names: list[str] | None = None
pose_representation: str = "componentwise"
se3_pose_groups: list[list[int]] = field(default_factory=list)
def __call__(self, transition: EnvTransition) -> EnvTransition:
observation = transition.get(TransitionKey.OBSERVATION, {})
state = observation.get(OBS_STATE)
if state is None:
raise ValueError("State is required for PI05")
if self.history_steps == 1 and state.ndim == 2:
state = state.unsqueeze(1)
if state.ndim != 3 or state.shape[1] != self.history_steps:
raise ValueError(
f"Expected state history with shape (B, {self.history_steps}, D), got {state.shape}"
)
processed_state = state.clone()
if self.relative:
mask_step = RelativeActionsProcessorStep(
enabled=True,
exclude_joints=self.exclude_joints,
action_names=self.state_names,
)
processed_state = to_relative_actions(
state,
state[:, -1],
mask_step._build_mask(state.shape[-1]),
pose_representation=self.pose_representation,
se3_pose_groups=self.se3_pose_groups,
)
flattened_dim = processed_state.shape[1] * processed_state.shape[2]
if flattened_dim > self.max_state_dim:
raise ValueError(
f"Flattened state history has {flattened_dim} dimensions, above max_state_dim={self.max_state_dim}"
)
new_transition = transition.copy()
new_observation = dict(observation)
new_observation[OBS_STATE] = processed_state.flatten(start_dim=1)
new_transition[TransitionKey.OBSERVATION] = new_observation
return new_transition
def get_config(self) -> dict[str, Any]:
return {
"history_steps": self.history_steps,
"max_state_dim": self.max_state_dim,
"relative": self.relative,
"exclude_joints": self.exclude_joints,
"state_names": self.state_names,
"pose_representation": self.pose_representation,
"se3_pose_groups": self.se3_pose_groups,
}
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
transformed = deepcopy(features)
for feature_group in transformed.values():
state_feature = feature_group.get(OBS_STATE)
if state_feature is not None:
state_dim = state_feature.shape[-1]
if self.relative:
source_dim = len(self.state_names) if self.state_names is not None else state_dim
model_dim = relative_action_output_dim(
source_dim,
self.pose_representation,
self.se3_pose_groups,
)
if state_dim == source_dim:
state_dim = model_dim
elif state_dim != model_dim:
raise ValueError(
f"Expected source/model state width {source_dim}/{model_dim}, got {state_dim}"
)
state_dim *= self.history_steps
feature_group[OBS_STATE] = PolicyFeature(type=state_feature.type, shape=(state_dim,))
return transformed
@ProcessorStepRegistry.register(name="pi05_prepare_state_tokenizer_processor_step")
@dataclass
class Pi05PrepareStateTokenizerProcessorStep(ProcessorStep):
@@ -133,13 +293,28 @@ def make_pi05_pre_post_processors(
enabled=config.use_relative_actions,
exclude_joints=getattr(config, "relative_exclude_joints", []),
action_names=getattr(config, "action_feature_names", None),
pose_representation=config.relative_pose_representation,
se3_pose_groups=config.relative_se3_pose_groups,
)
# OpenPI order: raw → relative → normalize → model → unnormalize → absolute
input_steps: list[ProcessorStep] = [
RenameObservationsProcessorStep(rename_map={}), # To mimic the same processor as pretrained one
AddBatchDimensionProcessorStep(),
Pi05StateFromActionProcessorStep(
enabled=config.state_from_action,
history_steps=config.proprioception_history_steps,
),
relative_step,
Pi05FlattenStateHistoryProcessorStep(
history_steps=config.proprioception_history_steps,
max_state_dim=config.max_state_dim,
relative=config.use_relative_state_history,
exclude_joints=config.relative_state_exclude_joints,
state_names=config.action_feature_names,
pose_representation=config.relative_pose_representation,
se3_pose_groups=config.relative_se3_pose_groups,
),
# NOTE: NormalizerProcessorStep MUST come before Pi05PrepareStateTokenizerProcessorStep
# because the tokenizer step expects normalized state in [-1, 1] range for discretization
NormalizerProcessorStep(
+4 -1
View File
@@ -121,7 +121,10 @@ class PiGemmaRMSNorm(nn.Module):
if cond.shape[-1] != self.cond_dim:
raise ValueError(f"Expected cond dim {self.cond_dim}, got {cond.shape[-1]}")
modulation = self.dense(cond)
if len(x.shape) == 3:
# Per-sample conditioning (B, D) is broadcast across the sequence.
# Training-time RTC supplies per-action conditioning (B, T, D), which
# is already aligned with x and must keep its token dimension intact.
if len(x.shape) == 3 and modulation.dim() == 2:
modulation = modulation.unsqueeze(1)
scale, shift, gate = modulation.chunk(3, dim=-1)
normed = normed * (1 + scale.float()) + shift.float()
@@ -37,6 +37,10 @@ class RTCConfig:
# Infrastructure
enabled: bool = True
# ``guided`` is the original inference-time Jacobian guidance. ``trained``
# hard-inpaints a prefix and requires a compatible training-time RTC checkpoint.
mode: str = "guided"
# Core RTC settings
# Todo change to exp
prefix_attention_schedule: RTCAttentionSchedule = RTCAttentionSchedule.LINEAR
@@ -49,6 +53,8 @@ class RTCConfig:
def __post_init__(self):
"""Validate RTC configuration parameters."""
if self.mode not in {"guided", "trained"}:
raise ValueError(f"mode must be 'guided' or 'trained', got {self.mode!r}")
if self.max_guidance_weight <= 0:
raise ValueError(f"max_guidance_weight must be positive, got {self.max_guidance_weight}")
if self.debug_maxlen <= 0:
+6 -1
View File
@@ -42,7 +42,12 @@ class RTCProcessor:
prefix attention, and adaptive chunk processing.
"""
def __init__(self, rtc_config: RTCConfig):
def __init__(self, rtc_config: RTCConfig, *, trained_mode_supported: bool = False):
if rtc_config.enabled and rtc_config.mode == "trained" and not trained_mode_supported:
raise ValueError(
"RTC mode='trained' requires a PI05-compatible checkpoint trained with "
"rtc_training_max_delay > 0."
)
self.rtc_config = rtc_config
self.tracker = None
+7 -1
View File
@@ -49,7 +49,13 @@ def reanchor_relative_rtc_prefix(
action_cpu = prev_actions_absolute.detach().cpu()
mask = relative_step._build_mask(action_cpu.shape[-1])
relative_actions = to_relative_actions(action_cpu, state, mask)
relative_actions = to_relative_actions(
action_cpu,
state,
mask,
pose_representation=relative_step.pose_representation,
se3_pose_groups=relative_step.se3_pose_groups,
)
transition = create_transition(action=relative_actions)
if normalizer_step is not None:
+16 -2
View File
@@ -89,8 +89,15 @@ from .policy_robot_bridge import (
from .relative_action_processor import (
AbsoluteActionsProcessorStep,
RelativeActionsProcessorStep,
relative_action_output_dim,
rotation_6d_to_rotvec,
rotvec_to_rotation_6d,
to_absolute_actions,
to_absolute_se3_pose,
to_absolute_se3_pose_6d,
to_relative_actions,
to_relative_se3_pose,
to_relative_se3_pose_6d,
)
from .rename_processor import RenameObservationsProcessorStep, rename_stats
from .tokenizer_processor import ActionTokenizerProcessorStep, TokenizerProcessorStep
@@ -135,6 +142,15 @@ __all__ = [
"make_default_robot_observation_processor",
"AbsoluteActionsProcessorStep",
"RelativeActionsProcessorStep",
"relative_action_output_dim",
"rotation_6d_to_rotvec",
"rotvec_to_rotation_6d",
"to_absolute_actions",
"to_absolute_se3_pose",
"to_absolute_se3_pose_6d",
"to_relative_actions",
"to_relative_se3_pose",
"to_relative_se3_pose_6d",
"MapDeltaActionToRobotActionStep",
"MapTensorToDeltaActionDictStep",
"NewLineTaskProcessorStep",
@@ -168,8 +184,6 @@ __all__ = [
"transition_to_batch",
"TransitionKey",
"TruncatedProcessorStep",
"to_absolute_actions",
"to_relative_actions",
"UnnormalizerProcessorStep",
"VanillaObservationProcessorStep",
]
@@ -21,7 +21,7 @@ from torch import Tensor
from lerobot.configs import PipelineFeatureType, PolicyFeature
from lerobot.types import EnvTransition, TransitionKey
from lerobot.utils.constants import OBS_STATE
from lerobot.utils.constants import ACTION, OBS_STATE
from .delta_action_processor import MapDeltaActionToRobotActionStep, MapTensorToDeltaActionDictStep
from .pipeline import ProcessorStep, ProcessorStepRegistry
@@ -34,57 +34,399 @@ __all__ = [
"AbsoluteActionsProcessorStep",
"to_relative_actions",
"to_absolute_actions",
"to_relative_se3_pose",
"to_absolute_se3_pose",
"to_relative_se3_pose_6d",
"to_absolute_se3_pose_6d",
"rotation_6d_to_rotvec",
"rotvec_to_rotation_6d",
"relative_action_output_dim",
]
def to_relative_actions(actions: Tensor, state: Tensor, mask: Sequence[bool]) -> Tensor:
"""Convert absolute actions to relative: relative = action - state (for masked dims).
def _rotvec_to_quaternion(rotvec: Tensor) -> Tensor:
angle = torch.linalg.vector_norm(rotvec, dim=-1, keepdim=True)
angle_sq = angle.square()
small_scale = 0.5 - angle_sq / 48.0 + angle_sq.square() / 3840.0
scale = torch.where(angle > 1e-6, torch.sin(angle / 2.0) / angle.clamp_min(1e-12), small_scale)
return torch.cat((torch.cos(angle / 2.0), rotvec * scale), dim=-1)
def _quaternion_to_rotvec(quaternion: Tensor) -> Tensor:
quaternion = quaternion / torch.linalg.vector_norm(quaternion, dim=-1, keepdim=True).clamp_min(1e-12)
quaternion = quaternion * torch.where(quaternion[..., :1] < 0, -1.0, 1.0)
vector = quaternion[..., 1:]
sin_half_angle = torch.linalg.vector_norm(vector, dim=-1, keepdim=True)
angle = 2.0 * torch.atan2(sin_half_angle, quaternion[..., :1].clamp_min(0.0))
small_scale = 2.0 + sin_half_angle.square() / 3.0
scale = torch.where(
sin_half_angle > 1e-6,
angle / sin_half_angle.clamp_min(1e-12),
small_scale,
)
return vector * scale
def _quaternion_multiply(left: Tensor, right: Tensor) -> Tensor:
left_w, left_xyz = left[..., :1], left[..., 1:]
right_w, right_xyz = right[..., :1], right[..., 1:]
return torch.cat(
(
left_w * right_w - (left_xyz * right_xyz).sum(dim=-1, keepdim=True),
left_w * right_xyz + right_w * left_xyz + torch.linalg.cross(left_xyz, right_xyz, dim=-1),
),
dim=-1,
)
def _quaternion_conjugate(quaternion: Tensor) -> Tensor:
return torch.cat((quaternion[..., :1], -quaternion[..., 1:]), dim=-1)
def _quaternion_rotate(quaternion: Tensor, vector: Tensor) -> Tensor:
quaternion_xyz = quaternion[..., 1:]
uv = torch.linalg.cross(quaternion_xyz, vector, dim=-1)
uuv = torch.linalg.cross(quaternion_xyz, uv, dim=-1)
return vector + 2.0 * (quaternion[..., :1] * uv + uuv)
def _quaternion_to_matrix(quaternion: Tensor) -> Tensor:
quaternion = quaternion / torch.linalg.vector_norm(quaternion, dim=-1, keepdim=True).clamp_min(1e-12)
w, x, y, z = quaternion.unbind(-1)
two_s = 2.0
return torch.stack(
(
1.0 - two_s * (y * y + z * z),
two_s * (x * y - z * w),
two_s * (x * z + y * w),
two_s * (x * y + z * w),
1.0 - two_s * (x * x + z * z),
two_s * (y * z - x * w),
two_s * (x * z - y * w),
two_s * (y * z + x * w),
1.0 - two_s * (x * x + y * y),
),
dim=-1,
).reshape(quaternion.shape[:-1] + (3, 3))
def _matrix_to_quaternion(matrix: Tensor) -> Tensor:
"""Convert proper rotation matrices to normalized ``[w, x, y, z]`` quaternions."""
if matrix.shape[-2:] != (3, 3):
raise ValueError(f"Rotation matrices must have shape (..., 3, 3), got {matrix.shape}")
m00 = matrix[..., 0, 0]
m01 = matrix[..., 0, 1]
m02 = matrix[..., 0, 2]
m10 = matrix[..., 1, 0]
m11 = matrix[..., 1, 1]
m12 = matrix[..., 1, 2]
m20 = matrix[..., 2, 0]
m21 = matrix[..., 2, 1]
m22 = matrix[..., 2, 2]
# Each row is a quaternion candidate scaled by the magnitude of its
# best-conditioned component. Selecting the largest component avoids the
# trace singularity at rotations close to pi.
q_abs = torch.sqrt(
torch.clamp(
torch.stack(
(
1.0 + m00 + m11 + m22,
1.0 + m00 - m11 - m22,
1.0 - m00 + m11 - m22,
1.0 - m00 - m11 + m22,
),
dim=-1,
),
min=0.0,
)
)
quat_by_rijk = torch.stack(
(
torch.stack((q_abs[..., 0].square(), m21 - m12, m02 - m20, m10 - m01), dim=-1),
torch.stack((m21 - m12, q_abs[..., 1].square(), m10 + m01, m02 + m20), dim=-1),
torch.stack((m02 - m20, m10 + m01, q_abs[..., 2].square(), m12 + m21), dim=-1),
torch.stack((m10 - m01, m02 + m20, m12 + m21, q_abs[..., 3].square()), dim=-1),
),
dim=-2,
)
candidates = quat_by_rijk / (2.0 * q_abs[..., :, None].clamp_min(0.1))
best = torch.nn.functional.one_hot(q_abs.argmax(dim=-1), num_classes=4).to(dtype=matrix.dtype)
quaternion = (candidates * best[..., :, None]).sum(dim=-2)
return quaternion / torch.linalg.vector_norm(quaternion, dim=-1, keepdim=True).clamp_min(1e-12)
def rotvec_to_rotation_6d(rotvec: Tensor) -> Tensor:
"""Encode an axis-angle rotation as the first two rotation-matrix rows."""
matrix = _quaternion_to_matrix(_rotvec_to_quaternion(rotvec))
return matrix[..., :2, :].reshape(matrix.shape[:-2] + (6,))
def rotation_6d_to_rotvec(rotation_6d: Tensor) -> Tensor:
"""Decode two predicted 3-D vectors into an axis-angle rotation.
Gram-Schmidt orthonormalization follows the continuous 6-D rotation
representation. Degenerate predictions fail closed instead of producing an
invalid physical rotation.
"""
if rotation_6d.shape[-1] != 6:
raise ValueError(f"6-D rotations must have six values, got {rotation_6d.shape}")
first = rotation_6d[..., :3]
second = rotation_6d[..., 3:]
first_norm = torch.linalg.vector_norm(first, dim=-1, keepdim=True)
first_unit = first / first_norm.clamp_min(1e-12)
second_orthogonal = second - (first_unit * second).sum(dim=-1, keepdim=True) * first_unit
second_norm = torch.linalg.vector_norm(second_orthogonal, dim=-1, keepdim=True)
if bool(torch.any(first_norm <= 1e-8)) or bool(torch.any(second_norm <= 1e-8)):
raise ValueError("Cannot decode a degenerate 6-D rotation prediction")
second_unit = second_orthogonal / second_norm
third_unit = torch.linalg.cross(first_unit, second_unit, dim=-1)
matrix = torch.stack((first_unit, second_unit, third_unit), dim=-2)
return _quaternion_to_rotvec(_matrix_to_quaternion(matrix))
def to_relative_se3_pose(target_pose: Tensor, reference_pose: Tensor) -> Tensor:
"""Encode a pose as ``inv(T_reference) @ T_target``.
Poses use ``[x, y, z, rx, ry, rz]`` with an axis-angle rotation vector.
The relative translation is therefore expressed in the reference EE frame.
"""
if target_pose.shape[-1] != 6 or reference_pose.shape[-1] != 6:
raise ValueError("SE(3) poses must have six values: xyz followed by a rotation vector")
reference_quaternion = _rotvec_to_quaternion(reference_pose[..., 3:])
target_quaternion = _rotvec_to_quaternion(target_pose[..., 3:])
inverse_reference_quaternion = _quaternion_conjugate(reference_quaternion)
relative_translation = _quaternion_rotate(
inverse_reference_quaternion, target_pose[..., :3] - reference_pose[..., :3]
)
relative_quaternion = _quaternion_multiply(inverse_reference_quaternion, target_quaternion)
return torch.cat((relative_translation, _quaternion_to_rotvec(relative_quaternion)), dim=-1)
def to_absolute_se3_pose(relative_pose: Tensor, reference_pose: Tensor) -> Tensor:
"""Decode a pose with ``T_target = T_reference @ T_relative``."""
if relative_pose.shape[-1] != 6 or reference_pose.shape[-1] != 6:
raise ValueError("SE(3) poses must have six values: xyz followed by a rotation vector")
reference_quaternion = _rotvec_to_quaternion(reference_pose[..., 3:])
relative_quaternion = _rotvec_to_quaternion(relative_pose[..., 3:])
target_translation = reference_pose[..., :3] + _quaternion_rotate(
reference_quaternion, relative_pose[..., :3]
)
target_quaternion = _quaternion_multiply(reference_quaternion, relative_quaternion)
return torch.cat((target_translation, _quaternion_to_rotvec(target_quaternion)), dim=-1)
def to_relative_se3_pose_6d(target_pose: Tensor, reference_pose: Tensor) -> Tensor:
"""Encode ``inv(T_reference) @ T_target`` as xyz plus continuous 6-D rotation."""
relative_pose = to_relative_se3_pose(target_pose, reference_pose)
return torch.cat((relative_pose[..., :3], rotvec_to_rotation_6d(relative_pose[..., 3:])), dim=-1)
def to_absolute_se3_pose_6d(relative_pose: Tensor, reference_pose: Tensor) -> Tensor:
"""Decode xyz plus continuous 6-D rotation with ``T_target = T_reference @ T_relative``."""
if relative_pose.shape[-1] != 9:
raise ValueError("6-D encoded SE(3) poses must have nine values: xyz plus rotation-6D")
relative_rotvec_pose = torch.cat(
(relative_pose[..., :3], rotation_6d_to_rotvec(relative_pose[..., 3:])), dim=-1
)
return to_absolute_se3_pose(relative_rotvec_pose, reference_pose)
def _broadcast_reference(actions: Tensor, state: Tensor) -> Tensor:
if state.device != actions.device or state.dtype != actions.dtype:
state = state.to(device=actions.device, dtype=actions.dtype)
if actions.ndim == state.ndim + 1:
state = state.unsqueeze(-2)
return state
def _validate_se3_pose_groups(
pose_representation: str,
se3_pose_groups: Sequence[Sequence[int]] | None,
mask: Sequence[bool],
action_dim: int,
) -> list[list[int]]:
if pose_representation not in {"componentwise", "se3", "se3_6d"}:
raise ValueError(
f"Unsupported pose_representation={pose_representation!r}; expected "
"'componentwise', 'se3', or 'se3_6d'"
)
if pose_representation == "componentwise":
return []
if not se3_pose_groups:
raise ValueError(
f"pose_representation={pose_representation!r} requires at least one six-index se3_pose_group"
)
normalized_groups: list[list[int]] = []
used_indices: set[int] = set()
for raw_group in se3_pose_groups:
group = [int(index) for index in raw_group]
if len(group) != 6:
raise ValueError(f"Each SE(3) pose group must contain six indices, got {group}")
if len(set(group)) != 6 or any(index < 0 or index >= action_dim for index in group):
raise ValueError(f"Invalid SE(3) pose group for action_dim={action_dim}: {group}")
if pose_representation == "se3_6d" and group != list(range(group[0], group[0] + 6)):
raise ValueError("se3_6d pose groups must contain six contiguous ascending indices")
if any(index >= len(mask) for index in group):
raise ValueError(f"SE(3) pose group lies outside the relative mask: {group}")
if used_indices.intersection(group):
raise ValueError(f"SE(3) pose groups must not overlap: {group}")
group_mask = [bool(mask[index]) for index in group]
if any(group_mask) and not all(group_mask):
raise ValueError(f"An SE(3) pose group must be wholly relative or wholly absolute: {group}")
used_indices.update(group)
if all(group_mask):
normalized_groups.append(group)
return normalized_groups
def relative_action_output_dim(
source_dim: int,
pose_representation: str,
se3_pose_groups: Sequence[Sequence[int]] | None,
) -> int:
"""Return the model-space action width for a source action width."""
if pose_representation != "se3_6d":
return source_dim
groups = se3_pose_groups or []
return source_dim + 3 * len(groups)
def _expand_se3_6d_actions(
actions: Tensor,
state: Tensor,
groups: Sequence[Sequence[int]],
) -> Tensor:
group_by_start = {group[0]: list(group) for group in groups}
grouped_indices = {index for group in groups for index in group}
parts: list[Tensor] = []
for index in range(actions.shape[-1]):
group = group_by_start.get(index)
if group is not None:
parts.append(to_relative_se3_pose_6d(actions[..., group], state[..., group]))
elif index not in grouped_indices:
parts.append(actions[..., index : index + 1])
return torch.cat(parts, dim=-1)
def _collapse_se3_6d_actions(
actions: Tensor,
state: Tensor,
mask: Sequence[bool],
groups: Sequence[Sequence[int]],
) -> Tensor:
source_dim = len(mask)
expected_dim = relative_action_output_dim(source_dim, "se3_6d", groups)
if actions.shape[-1] != expected_dim:
raise ValueError(
f"Expected se3_6d action width {expected_dim} for source width {source_dim}, "
f"got {actions.shape[-1]}"
)
group_by_start = {group[0]: list(group) for group in groups}
grouped_indices = {index for group in groups for index in group}
parts: list[Tensor] = []
cursor = 0
for index in range(source_dim):
group = group_by_start.get(index)
if group is not None:
parts.append(to_absolute_se3_pose_6d(actions[..., cursor : cursor + 9], state[..., group]))
cursor += 9
elif index not in grouped_indices:
value = actions[..., cursor : cursor + 1]
if mask[index]:
value = value + state[..., index : index + 1]
parts.append(value)
cursor += 1
if cursor != actions.shape[-1]:
raise RuntimeError(f"Consumed {cursor} action values from width {actions.shape[-1]}")
return torch.cat(parts, dim=-1)
def to_relative_actions(
actions: Tensor,
state: Tensor,
mask: Sequence[bool],
*,
pose_representation: str = "componentwise",
se3_pose_groups: Sequence[Sequence[int]] | None = None,
) -> Tensor:
"""Convert absolute actions to a configured relative representation.
Component-wise mode computes ``action - state``. SE(3) modes compute
``inv(T_state) @ T_action`` for each configured pose group. ``se3_6d``
replaces each three-value relative rotation vector with its continuous
six-value encoding, increasing the output width by three per pose group.
Args:
actions: (B, T, action_dim) or (B, action_dim).
state: (B, state_dim). Broadcast across time dimension.
mask: Which dims to convert. Can be shorter than action_dim.
"""
groups = _validate_se3_pose_groups(pose_representation, se3_pose_groups, mask, actions.shape[-1])
mask_t = torch.tensor(mask, dtype=actions.dtype, device=actions.device)
dims = mask_t.shape[0]
# Align state to the same device/dtype as actions. _last_state is cached before
# DeviceProcessorStep moves the transition, so it can be on CPU while actions are on CUDA.
if state.device != actions.device or state.dtype != actions.dtype:
state = state.to(device=actions.device, dtype=actions.dtype)
state_offset = state[..., :dims] * mask_t
if actions.ndim == 3:
state_offset = state_offset.unsqueeze(-2)
state = _broadcast_reference(actions, state)
component_mask = mask_t.clone()
for group in groups:
component_mask[group] = 0
state_offset = state[..., :dims] * component_mask
actions = actions.clone()
actions[..., :dims] -= state_offset
if pose_representation == "se3_6d":
return _expand_se3_6d_actions(actions, state, groups)
for group in groups:
actions[..., group] = to_relative_se3_pose(actions[..., group], state[..., group])
return actions
def to_absolute_actions(actions: Tensor, state: Tensor, mask: Sequence[bool]) -> Tensor:
"""Convert relative actions back to absolute: absolute = relative + state (for masked dims).
def to_absolute_actions(
actions: Tensor,
state: Tensor,
mask: Sequence[bool],
*,
pose_representation: str = "componentwise",
se3_pose_groups: Sequence[Sequence[int]] | None = None,
) -> Tensor:
"""Convert relative actions back to absolute actions.
Component-wise mode computes ``relative + state``. SE(3) mode computes
``T_state @ T_relative`` for each configured pose group.
Args:
actions: (B, T, action_dim) or (B, action_dim).
state: (B, state_dim). Broadcast across time dimension.
mask: Which dims to convert. Can be shorter than action_dim.
"""
source_dim = len(mask)
groups = _validate_se3_pose_groups(pose_representation, se3_pose_groups, mask, source_dim)
state = _broadcast_reference(actions, state)
if pose_representation == "se3_6d":
return _collapse_se3_6d_actions(actions, state, mask, groups)
mask_t = torch.tensor(mask, dtype=actions.dtype, device=actions.device)
dims = mask_t.shape[0]
# Align state to the same device/dtype as actions. _last_state is cached before
# DeviceProcessorStep moves the transition, so it can be on CPU while actions are on CUDA.
if state.device != actions.device or state.dtype != actions.dtype:
state = state.to(device=actions.device, dtype=actions.dtype)
state_offset = state[..., :dims] * mask_t
if actions.ndim == 3:
state_offset = state_offset.unsqueeze(-2)
state = _broadcast_reference(actions, state)
component_mask = mask_t.clone()
for group in groups:
component_mask[group] = 0
state_offset = state[..., :dims] * component_mask
actions = actions.clone()
actions[..., :dims] += state_offset
for group in groups:
actions[..., group] = to_absolute_se3_pose(actions[..., group], state[..., group])
return actions
@ProcessorStepRegistry.register("relative_actions_processor")
@dataclass
class RelativeActionsProcessorStep(ProcessorStep):
"""Converts absolute actions to relative actions (action -= state) for masked dimensions.
"""Converts absolute actions to the configured relative representation.
Mirrors OpenPI's DeltaActions transform. Applied during preprocessing so the model
trains on relative offsets instead of absolute positions.
@@ -101,7 +443,10 @@ class RelativeActionsProcessorStep(ProcessorStep):
enabled: bool = False
exclude_joints: list[str] = field(default_factory=list)
action_names: list[str] | None = None
pose_representation: str = "componentwise"
se3_pose_groups: list[list[int]] = field(default_factory=list)
_last_state: torch.Tensor | None = field(default=None, init=False, repr=False)
_last_mask: list[bool] | None = field(default=None, init=False, repr=False)
def _build_mask(self, action_dim: int) -> list[bool]:
if not self.exclude_joints or self.action_names is None:
@@ -126,37 +471,78 @@ class RelativeActionsProcessorStep(ProcessorStep):
observation = transition.get(TransitionKey.OBSERVATION, {})
state = observation.get(OBS_STATE) if observation else None
# State history has shape (B, H, D). Relative actions are referenced to
# the newest proprioceptive state, not the whole history tensor.
reference_state = state[:, -1] if state is not None and state.ndim == 3 else state
# Always cache state for the paired AbsoluteActionsProcessorStep
if state is not None:
self._last_state = state
if reference_state is not None:
self._last_state = reference_state
self._last_mask = self._build_mask(reference_state.shape[-1])
if not self.enabled:
return transition
new_transition = transition.copy()
action = new_transition.get(TransitionKey.ACTION)
if action is None or state is None:
if action is None or reference_state is None:
return new_transition
mask = self._build_mask(action.shape[-1])
new_transition[TransitionKey.ACTION] = to_relative_actions(action, state, mask)
mask = self._last_mask or self._build_mask(action.shape[-1])
new_transition[TransitionKey.ACTION] = to_relative_actions(
action,
reference_state,
mask,
pose_representation=self.pose_representation,
se3_pose_groups=self.se3_pose_groups,
)
return new_transition
def get_cached_state(self) -> torch.Tensor | None:
"""Return the cached ``observation.state`` used as the reference point for relative/absolute action conversions."""
return self._last_state
def get_cached_mask(self) -> list[bool] | None:
"""Return the source-space mask cached with the latest state."""
return self._last_mask
def reset(self) -> None:
"""Drop the inference reference so it cannot leak between sessions."""
self._last_state = None
self._last_mask = None
def get_config(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"exclude_joints": self.exclude_joints,
"action_names": self.action_names,
"pose_representation": self.pose_representation,
"se3_pose_groups": self.se3_pose_groups,
}
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
return features
if not self.enabled or self.pose_representation != "se3_6d":
return features
transformed = {feature_type: dict(feature_group) for feature_type, feature_group in features.items()}
for feature_group in transformed.values():
action_feature = feature_group.get(ACTION)
if action_feature is None:
continue
source_dim = len(self.action_names) if self.action_names is not None else action_feature.shape[-1]
model_dim = relative_action_output_dim(source_dim, self.pose_representation, self.se3_pose_groups)
if action_feature.shape[-1] == source_dim:
feature_group[ACTION] = PolicyFeature(
type=action_feature.type,
shape=(model_dim,),
)
elif action_feature.shape[-1] != model_dim:
raise ValueError(
f"Expected source/model action width {source_dim}/{model_dim}, "
f"got {action_feature.shape[-1]}"
)
return transformed
@ProcessorStepRegistry.register("absolute_actions_processor")
@@ -198,8 +584,16 @@ class AbsoluteActionsProcessorStep(ProcessorStep):
if action is None:
return new_transition
mask = self.relative_step._build_mask(action.shape[-1])
new_transition[TransitionKey.ACTION] = to_absolute_actions(action, cached_state, mask)
mask = self.relative_step.get_cached_mask()
if mask is None:
mask = self.relative_step._build_mask(cached_state.shape[-1])
new_transition[TransitionKey.ACTION] = to_absolute_actions(
action,
cached_state,
mask,
pose_representation=self.relative_step.pose_representation,
se3_pose_groups=self.relative_step.se3_pose_groups,
)
return new_transition
def get_config(self) -> dict[str, Any]:
+69 -4
View File
@@ -46,6 +46,7 @@ from lerobot.processor import (
from lerobot.processor.relative_action_processor import RelativeActionsProcessorStep
from lerobot.robots import make_robot_from_config
from lerobot.teleoperators import Teleoperator, make_teleoperator_from_config
from lerobot.utils.constants import OBS_STATE
from lerobot.utils.feature_utils import combine_feature_dicts, hw_to_dataset_features
from .configs import BaseStrategyConfig, DAggerStrategyConfig, RolloutConfig
@@ -60,6 +61,35 @@ from .robot_wrapper import ThreadSafeRobot
logger = logging.getLogger(__name__)
def _validate_trained_rtc_rollout_config(policy_config, inference_config: RTCInferenceConfig) -> None:
"""Fail fast when rollout cannot retain every trained RTC prefix."""
rtc = inference_config.rtc
if not rtc.enabled or rtc.mode != "trained":
return
if policy_config.type not in {"pi05", "pi052"}:
raise ValueError(
"--inference.rtc.mode=trained currently requires a PI05-compatible checkpoint; "
f"got policy type {policy_config.type!r}."
)
training_max_delay = int(getattr(policy_config, "rtc_training_max_delay", 0))
if training_max_delay <= 0:
raise ValueError(
"--inference.rtc.mode=trained requires a checkpoint trained with "
"--policy.rtc_training_max_delay > 0."
)
if rtc.execution_horizon < training_max_delay:
raise ValueError(
f"--inference.rtc.execution_horizon ({rtc.execution_horizon}) must be at least the "
f"checkpoint's rtc_training_max_delay ({training_max_delay})."
)
if inference_config.queue_threshold < training_max_delay:
raise ValueError(
f"--inference.queue_threshold ({inference_config.queue_threshold}) must be at least the "
f"checkpoint's rtc_training_max_delay ({training_max_delay})."
)
def _resolve_action_key_order(
policy_action_names: list[str] | None, dataset_action_names: list[str]
) -> list[str]:
@@ -80,6 +110,26 @@ def _resolve_action_key_order(
return policy_action_names
def _align_relative_state_feature_order(
hw_features: dict[str, dict], policy_action_names: list[str] | None
) -> dict[str, dict]:
"""Align policy-facing state with named relative-action dimensions."""
if not policy_action_names or OBS_STATE not in hw_features:
return hw_features
state_feature = hw_features[OBS_STATE]
state_names = state_feature.get("names")
if not state_names or len(state_names) != len(policy_action_names):
return hw_features
if set(state_names) != set(policy_action_names) or state_names == policy_action_names:
return hw_features
aligned = dict(hw_features)
aligned[OBS_STATE] = {**state_feature, "names": list(policy_action_names)}
logger.info("Aligned relative-action state order with checkpoint action names")
return aligned
# ---------------------------------------------------------------------------
# Sub-contexts
# ---------------------------------------------------------------------------
@@ -178,6 +228,9 @@ def build_rollout_context(
policy_config = cfg.policy
policy_class = get_policy_class(policy_config.type)
if is_rtc:
_validate_trained_rtc_rollout_config(policy_config, cfg.inference)
if hasattr(policy_config, "compile_model"):
policy_config.compile_model = cfg.use_torch_compile
@@ -399,10 +452,22 @@ def build_rollout_context(
},
)
if isinstance(cfg.inference, SyncInferenceConfig) and any(
isinstance(step, RelativeActionsProcessorStep) and step.enabled
for step in getattr(preprocessor, "steps", ())
):
relative_action_step = next(
(
step
for step in getattr(preprocessor, "steps", ())
if isinstance(step, RelativeActionsProcessorStep) and step.enabled
),
None,
)
if relative_action_step is not None:
relative_action_names = relative_action_step.action_names or policy_action_names
hw_features = _align_relative_state_feature_order(
hw_features,
list(relative_action_names) if relative_action_names else None,
)
if isinstance(cfg.inference, SyncInferenceConfig) and relative_action_step is not None:
raise NotImplementedError(
"SyncInferenceEngine does not support policies with relative actions for now."
"Use --inference.type=rtc or remove relative action processor steps from the policy pipeline."
+95 -2
View File
@@ -57,6 +57,18 @@ _RTC_MAX_CONSECUTIVE_ERRORS: int = 10
_RTC_JOIN_TIMEOUT_S: float = 3.0
class _FatalRTCInferenceError(RuntimeError):
"""Base class for RTC errors that cannot become valid after a retry."""
class _TrainedRTCDelayExceededError(_FatalRTCInferenceError):
"""Raised when measured latency exceeds a trained RTC checkpoint's support."""
class _TrainedRTCPrefixUnavailableError(_FatalRTCInferenceError):
"""Raised when the queue cannot provide the prefix used for conditioning."""
# ---------------------------------------------------------------------------
# RTC helpers
# ---------------------------------------------------------------------------
@@ -76,6 +88,50 @@ def _normalize_prev_actions_length(prev_actions: torch.Tensor, target_steps: int
return padded
def _trained_rtc_chunk_can_merge(
*,
conditioned_delay: int,
measured_delay: int,
training_max_delay: int,
has_previous_actions: bool,
) -> bool:
"""Check that a trained RTC chunk covers the overlap observed during inference."""
if not has_previous_actions:
return True
if measured_delay > training_max_delay:
raise _TrainedRTCDelayExceededError(
f"Measured RTC inference delay ({measured_delay}) exceeds the checkpoint's "
f"rtc_training_max_delay ({training_max_delay})."
)
return measured_delay <= conditioned_delay
def _estimate_rtc_delay(
*,
latency: float,
time_per_step: float,
mode: str,
training_max_delay: int,
has_previous_actions: bool,
) -> int:
"""Estimate overlap, using the trained capacity to bootstrap the first transition."""
if latency:
return math.ceil(latency / time_per_step)
if mode == "trained" and has_previous_actions:
return training_max_delay
return 0
def _validate_trained_rtc_prefix_available(*, conditioned_delay: int, available_steps: int) -> None:
"""Reject hard-prefix inference when the real queue is shorter than its delay."""
if conditioned_delay > available_steps:
raise _TrainedRTCPrefixUnavailableError(
f"Trained RTC needs {conditioned_delay} committed prefix actions, but the queue has "
f"only {available_steps}. Increase --inference.queue_threshold and "
"--inference.rtc.execution_horizon."
)
# ---------------------------------------------------------------------------
# RTCInferenceEngine
# ---------------------------------------------------------------------------
@@ -272,9 +328,23 @@ class RTCInferenceEngine(InferenceEngine):
current_time = time.perf_counter()
idx_before = queue.get_action_index()
prev_actions = queue.get_left_over()
has_previous_actions = prev_actions is not None and prev_actions.numel() > 0
training_max_delay = int(getattr(self._policy.config, "rtc_training_max_delay", 0))
latency = latency_tracker.max()
delay = math.ceil(latency / time_per_chunk) if latency else 0
delay = _estimate_rtc_delay(
latency=latency,
time_per_step=time_per_chunk,
mode=self._rtc_config.mode,
training_max_delay=training_max_delay,
has_previous_actions=has_previous_actions,
)
if self._rtc_config.mode == "trained" and delay > 0:
available_steps = 0 if prev_actions is None else prev_actions.shape[0]
_validate_trained_rtc_prefix_available(
conditioned_delay=delay,
available_steps=available_steps,
)
obs_batch = build_dataset_frame(self._hw_features, obs, prefix="observation")
obs_batch = prepare_observation_for_inference(
@@ -316,11 +386,32 @@ class RTCInferenceEngine(InferenceEngine):
inference_count += 1
consecutive_errors = 0
is_warmup = self._use_torch_compile and inference_count <= warmup_required
if is_warmup:
is_initial_trained_chunk = (
self._rtc_config.mode == "trained" and not has_previous_actions
)
if is_warmup or is_initial_trained_chunk:
latency_tracker.reset()
else:
latency_tracker.add(new_latency)
if (
not is_warmup
and self._rtc_config.mode == "trained"
and not _trained_rtc_chunk_can_merge(
conditioned_delay=delay,
measured_delay=new_delay,
training_max_delay=training_max_delay,
has_previous_actions=has_previous_actions,
)
):
logger.warning(
"Discarding trained RTC chunk: measured delay %d exceeded "
"conditioned delay %d; retrying with updated latency",
new_delay,
delay,
)
continue
queue.merge(original, processed, new_delay, idx_before)
if (
@@ -333,6 +424,8 @@ class RTCInferenceEngine(InferenceEngine):
logger.debug("RTC inference latency=%.2fs, queue=%d", new_latency, queue.qsize())
except _FatalRTCInferenceError:
raise
except Exception as e:
consecutive_errors += 1
logger.error(
+7 -48
View File
@@ -167,9 +167,7 @@ Show dataset information without feature details:
--operation.type info \
--operation.show_features false
Recompute dataset statistics (saves to lerobot/pusht_recomputed_stats by default). The source
dataset is never modified: large files are symlinked and only meta/ is copied, so this also works
on read-only source datasets:
Recompute dataset statistics (saves to lerobot/pusht_recomputed_stats by default):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type recompute_stats
@@ -180,19 +178,6 @@ Recompute stats and save to a specific new repo_id:
--new_repo_id lerobot/pusht_new_stats \
--operation.type recompute_stats
Recompute stats including image/video features (samples and decodes frames from each episode):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type recompute_stats \
--operation.skip_image_video false
Recompute stats and also rewrite the per-episode stats in the episodes parquet (keeps
meta/stats.json and the per-episode stats consistent):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type recompute_stats \
--operation.update_episode_stats true
Recompute stats in-place (overwrites original dataset stats):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
@@ -340,7 +325,8 @@ class RecomputeStatsConfig(OperationConfig):
relative_exclude_joints: list[str] | None = None
chunk_size: int = 50
num_workers: int = 0
update_episode_stats: bool = False
relative_pose_representation: str = "componentwise"
relative_se3_pose_groups: list[list[int]] | None = None
overwrite: bool = False
@@ -393,30 +379,6 @@ def _resolve_io_paths(
return output_repo_id, input_path, output_path
def _reference_copy_dataset(input_root: Path, output_root: Path) -> None:
"""Create a lightweight copy of a dataset that never modifies the source.
The directory tree is recreated with real directories, and every file is
symlinked to its source counterpart so no data is duplicated and the source is
only ever read. Files under ``meta/`` are instead copied as real, writable files
so that stats/info can be rewritten without touching the original. Symlinking
individual files (rather than whole directories) keeps ``push_to_hub`` working,
since ``Path.glob`` follows file symlinks but does not descend into symlinked
directories. This makes the operation safe on read-only source datasets.
"""
for src in input_root.rglob("*"):
rel = src.relative_to(input_root)
dst = output_root / rel
if src.is_dir():
dst.mkdir(parents=True, exist_ok=True)
elif rel.parts[0] == "meta":
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst) # copyfile ignores source perms, so dst is writable
else:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.symlink_to(src.resolve())
def get_output_path(
repo_id: str,
new_repo_id: str | None,
@@ -714,18 +676,14 @@ def handle_recompute_stats(cfg: EditDatasetConfig) -> None:
)
dataset = LeRobotDataset(cfg.repo_id, root=input_root)
else:
logging.info(f"Referencing dataset from {input_root} into {output_root} (source is left untouched)")
logging.info(f"Copying dataset from {input_root} to {output_root}")
if output_root.exists():
backup_path = output_root.with_name(output_root.name + "_old")
logging.warning(f"Output directory {output_root} already exists. Moving to {backup_path}")
if backup_path.exists():
shutil.rmtree(backup_path)
shutil.move(output_root, backup_path)
# recompute_stats only reads data/ and rewrites files under meta/ (stats.json, and
# the episodes parquet when update_episode_stats is set), so symlink the large
# immutable files and copy only meta/. This avoids duplicating the dataset and works
# even when the source dataset is read-only.
_reference_copy_dataset(input_root, output_root)
shutil.copytree(input_root, output_root)
dataset = LeRobotDataset(output_repo_id, root=output_root)
logging.info(f"Recomputing stats for {cfg.repo_id}")
@@ -742,7 +700,8 @@ def handle_recompute_stats(cfg: EditDatasetConfig) -> None:
relative_exclude_joints=cfg.operation.relative_exclude_joints,
chunk_size=cfg.operation.chunk_size,
num_workers=cfg.operation.num_workers,
update_episode_stats=cfg.operation.update_episode_stats,
relative_pose_representation=cfg.operation.relative_pose_representation,
relative_se3_pose_groups=cfg.operation.relative_se3_pose_groups,
)
logging.info(f"Stats written to {dataset.root}")
+2
View File
@@ -343,6 +343,8 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
"enabled": True,
"exclude_joints": getattr(active_cfg, "relative_exclude_joints", []),
"action_names": getattr(active_cfg, "action_feature_names", None),
"pose_representation": getattr(active_cfg, "relative_pose_representation", "componentwise"),
"se3_pose_groups": getattr(active_cfg, "relative_se3_pose_groups", []),
}
postprocessor_overrides["absolute_actions_processor"] = {"enabled": True}
processor_kwargs["preprocessor_overrides"] = preprocessor_overrides
@@ -0,0 +1,94 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
import pytest
import torch
pytest.importorskip("transformers")
from lerobot.policies.pi05.configuration_pi05 import PI05Config # noqa: E402
from lerobot.policies.pi05.modeling_pi05 import ( # noqa: E402
_build_flow_matching_inputs,
_prepare_trained_rtc_prefix,
_reduce_training_rtc_loss,
create_sinusoidal_pos_embedding,
)
from lerobot.policies.pi_gemma import PiGemmaRMSNorm # noqa: E402
def test_pi05_training_rtc_uses_clean_prefix_and_per_token_time():
actions = torch.tensor([[[1.0], [2.0], [3.0], [4.0]]])
noise = torch.tensor([[[10.0], [20.0], [30.0], [40.0]]])
time = torch.tensor([0.25])
prefix_mask = torch.tensor([[True, True, False, False]])
x_t, model_time = _build_flow_matching_inputs(actions, noise, time, prefix_mask)
assert model_time.tolist() == [[0.0, 0.0, 0.25, 0.25]]
assert torch.equal(x_t[:, :2], actions[:, :2])
assert torch.equal(x_t[:, 2:], 0.25 * noise[:, 2:] + 0.75 * actions[:, 2:])
def test_pi05_training_rtc_loss_excludes_clean_prefix():
losses = torch.tensor([[[100.0], [100.0], [2.0], [4.0]]])
prefix_mask = torch.tensor([[True, True, False, False]])
loss = _reduce_training_rtc_loss(losses, prefix_mask, reduction="mean")
assert loss.item() == pytest.approx(3.0)
def test_pi05_training_rtc_adaptive_norm_accepts_per_action_time_conditioning():
norm = PiGemmaRMSNorm(dim=4, cond_dim=3)
hidden = torch.randn(2, 5, 4)
per_action_condition = torch.randn(2, 5, 3)
output, gate = norm(hidden, per_action_condition)
assert output.shape == hidden.shape
assert gate.shape == hidden.shape
def test_pi05_training_rtc_embeds_per_action_timesteps():
per_action_time = torch.tensor([[0.0, 0.0, 0.25, 0.25]])
embedding = create_sinusoidal_pos_embedding(
per_action_time,
dimension=8,
min_period=4e-3,
max_period=4.0,
device=per_action_time.device,
)
assert embedding.shape == (1, 4, 8)
torch.testing.assert_close(embedding[:, 0], embedding[:, 1])
torch.testing.assert_close(embedding[:, 2], embedding[:, 3])
def test_pi05_trained_rtc_prefix_is_padded_to_model_width():
latent = torch.zeros(1, 5, 32)
previous = torch.arange(30, dtype=torch.float32).reshape(1, 3, 10)
prefix, mask = _prepare_trained_rtc_prefix(
latent,
previous,
inference_delay=2,
training_max_delay=4,
)
assert prefix.shape == latent.shape
assert mask.shape == latent.shape
torch.testing.assert_close(prefix[:, :2, :10], previous[:, :2])
assert torch.count_nonzero(prefix[:, :2, 10:]) == 0
assert mask[:, :2].all()
assert not mask[:, 2:].any()
@pytest.mark.parametrize("max_delay", [-1, 5])
def test_pi05_config_rejects_invalid_training_rtc_delay(max_delay):
with pytest.raises(ValueError, match="rtc_training_max_delay"):
PI05Config(chunk_size=5, n_action_steps=5, rtc_training_max_delay=max_delay)
@@ -16,6 +16,8 @@
"""Tests for RTC configuration module."""
import pytest
from lerobot.configs.types import RTCAttentionSchedule
from lerobot.policies.rtc.configuration_rtc import RTCConfig
@@ -27,6 +29,7 @@ def test_rtc_config_default_initialization():
config = RTCConfig()
assert config.enabled is True
assert config.mode == "guided"
assert config.prefix_attention_schedule == RTCAttentionSchedule.LINEAR
assert config.max_guidance_weight == 10.0
assert config.execution_horizon == 10
@@ -34,10 +37,16 @@ def test_rtc_config_default_initialization():
assert config.debug_maxlen == 100
def test_rtc_config_rejects_unknown_mode():
with pytest.raises(ValueError, match="mode must be"):
RTCConfig(mode="unknown")
def test_rtc_config_custom_initialization():
"""Test RTCConfig initializes with custom values."""
config = RTCConfig(
enabled=True,
mode="trained",
prefix_attention_schedule=RTCAttentionSchedule.EXP,
max_guidance_weight=5.0,
execution_horizon=20,
@@ -46,6 +55,7 @@ def test_rtc_config_custom_initialization():
)
assert config.enabled is True
assert config.mode == "trained"
assert config.prefix_attention_schedule == RTCAttentionSchedule.EXP
assert config.max_guidance_weight == 5.0
assert config.execution_horizon == 20
+13
View File
@@ -93,6 +93,19 @@ def test_rtc_processor_initialization_without_debug(rtc_config_debug_disabled):
assert processor.tracker is None
def test_rtc_processor_rejects_trained_mode_when_policy_does_not_support_it():
config = RTCConfig(mode="trained")
with pytest.raises(ValueError, match="requires a PI05-compatible checkpoint"):
RTCProcessor(config)
processor = RTCProcessor(config, trained_mode_supported=True)
assert processor.rtc_config.mode == "trained"
disabled = RTCProcessor(RTCConfig(enabled=False, mode="trained"))
assert disabled.rtc_config.enabled is False
# ====================== Tracker Proxy Methods Tests ======================
@@ -505,6 +505,44 @@ class TestRTCReanchoringWithStateNormalizer:
assert not torch.allclose(cached, post_normalize_state, atol=1e-3)
def test_reanchor_se3_6d_prefix_uses_current_camera_frame_and_model_width():
"""A leftover absolute EE chunk is recomposed relative to the latest camera-frame EE pose."""
names = ["pos_x", "pos_y", "pos_z", "rot_x", "rot_y", "rot_z", "gripper"]
relative_step = RelativeActionsProcessorStep(
enabled=True,
exclude_joints=["gripper"],
action_names=names,
pose_representation="se3_6d",
se3_pose_groups=[list(range(6))],
)
current_state = torch.tensor([[0.20, -0.10, 0.40, 0.31, -0.22, 0.17, 0.03]])
previous_absolute = torch.tensor(
[
[0.28, -0.03, 0.46, -0.18, 0.27, 0.41, 0.02],
[0.31, 0.02, 0.50, -0.11, 0.35, 0.52, 0.01],
]
)
result = reanchor_relative_rtc_prefix(
prev_actions_absolute=previous_absolute,
current_state=current_state,
relative_step=relative_step,
normalizer_step=None,
policy_device="cpu",
)
expected = to_relative_actions(
previous_absolute,
current_state,
relative_step._build_mask(previous_absolute.shape[-1]),
pose_representation="se3_6d",
se3_pose_groups=[list(range(6))],
)
assert result.shape == (2, 10)
torch.testing.assert_close(result, expected, atol=1e-6, rtol=1e-6)
torch.testing.assert_close(result[:, -1], previous_absolute[:, -1])
def _detect_relative_actions(preprocessor) -> bool:
"""Mirror of the helper in lerobot-rollout for testing without importing it."""
return any(isinstance(step, RelativeActionsProcessorStep) and step.enabled for step in preprocessor.steps)
@@ -0,0 +1,321 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# 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.
from math import pi
import numpy as np
import pytest
import torch
pytest.importorskip("transformers")
from lerobot.configs import FeatureType, PolicyFeature # noqa: E402
from lerobot.datasets.compute_stats import ( # noqa: E402
compute_relative_action_stats,
compute_state_history_stats,
)
from lerobot.policies.pi05.configuration_pi05 import PI05Config # noqa: E402
from lerobot.policies.pi05.processor_pi05 import ( # noqa: E402
Pi05FlattenStateHistoryProcessorStep,
Pi05StateFromActionProcessorStep,
)
from lerobot.processor.relative_action_processor import ( # noqa: E402
AbsoluteActionsProcessorStep,
RelativeActionsProcessorStep,
)
from lerobot.types import TransitionKey # noqa: E402
from lerobot.utils.constants import ACTION, OBS_STATE # noqa: E402
def _transition(action: torch.Tensor | None, state: torch.Tensor | None = None) -> dict:
observation = {} if state is None else {OBS_STATE: state}
return {
TransitionKey.OBSERVATION: observation,
TransitionKey.ACTION: action,
TransitionKey.REWARD: None,
TransitionKey.DONE: None,
TransitionKey.TRUNCATED: None,
TransitionKey.COMPLEMENTARY_DATA: {},
}
def test_pi05_config_requests_action_history_prefix():
config = PI05Config(
device="cpu",
chunk_size=4,
n_action_steps=4,
state_from_action=True,
proprioception_history_steps=2,
)
assert config.action_delta_indices == [-1, 0, 1, 2, 3]
def test_pi05_config_accepts_se3_6d_action_and_state_with_two_step_history():
names = ["x", "y", "z", "rx", "ry", "rz", "gripper_width"]
config = PI05Config(
device="cpu",
use_relative_actions=True,
state_from_action=True,
proprioception_history_steps=2,
use_relative_state_history=True,
relative_pose_representation="se3_6d",
action_feature_names=names,
output_features={
ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(7,)),
},
)
config.validate_features()
assert config.output_features[ACTION].shape == (10,)
assert config.input_features[OBS_STATE].shape == (10,)
def test_state_from_action_extracts_history_and_preserves_target_horizon():
action = torch.arange(2 * 5 * 3, dtype=torch.float32).reshape(2, 5, 3)
step = Pi05StateFromActionProcessorStep(enabled=True, history_steps=2)
result = step(_transition(action))
torch.testing.assert_close(result[TransitionKey.OBSERVATION][OBS_STATE], action[:, :2])
torch.testing.assert_close(result[TransitionKey.ACTION], action[:, 1:])
def test_relative_actions_use_newest_state_in_history_and_roundtrip():
state_history = torch.tensor([[[1.0, 10.0], [2.0, 20.0]]])
absolute = torch.tensor([[[3.0, 30.0], [4.0, 40.0]]])
relative_step = RelativeActionsProcessorStep(enabled=True)
absolute_step = AbsoluteActionsProcessorStep(enabled=True, relative_step=relative_step)
relative = relative_step(_transition(absolute, state_history))
expected = torch.tensor([[[1.0, 10.0], [2.0, 20.0]]])
torch.testing.assert_close(relative[TransitionKey.ACTION], expected)
recovered = absolute_step(_transition(relative[TransitionKey.ACTION]))
torch.testing.assert_close(recovered[TransitionKey.ACTION], absolute)
def test_relative_action_reference_is_reset_between_inference_sessions():
step = RelativeActionsProcessorStep(enabled=True)
step(_transition(None, torch.tensor([[1.0, 2.0]])))
step.reset()
assert step.get_cached_state() is None
assert step.get_cached_mask() is None
def test_flatten_state_history_preserves_chronological_order():
state_history = torch.tensor([[[1.0, 2.0], [3.0, 4.0]]])
step = Pi05FlattenStateHistoryProcessorStep(history_steps=2, max_state_dim=4)
result = step(_transition(torch.zeros(1, 2, 2), state_history))
torch.testing.assert_close(
result[TransitionKey.OBSERVATION][OBS_STATE], torch.tensor([[1.0, 2.0, 3.0, 4.0]])
)
def test_state_history_can_be_relative_with_absolute_gripper():
state_history = torch.tensor([[[1.0, 10.0, 0.2], [3.0, 20.0, 0.4]]])
step = Pi05FlattenStateHistoryProcessorStep(
history_steps=2,
max_state_dim=6,
relative=True,
exclude_joints=["gripper"],
state_names=["x", "y", "gripper_width"],
)
result = step(_transition(torch.zeros(1, 2, 3), state_history))
torch.testing.assert_close(
result[TransitionKey.OBSERVATION][OBS_STATE],
torch.tensor([[-2.0, -10.0, 0.2, 0.0, 0.0, 0.4]]),
)
def test_state_history_can_use_se3_composition_with_absolute_gripper():
state_history = torch.tensor(
[[[0.0, 1.0, 0.0, 0.0, 0.0, pi / 2, 0.2], [0.0, 0.0, 0.0, 0.0, 0.0, pi / 2, 0.4]]]
)
step = Pi05FlattenStateHistoryProcessorStep(
history_steps=2,
max_state_dim=14,
relative=True,
exclude_joints=["gripper"],
state_names=["x", "y", "z", "rx", "ry", "rz", "gripper_width"],
pose_representation="se3",
se3_pose_groups=[list(range(6))],
)
result = step(_transition(torch.zeros(1, 2, 7), state_history))
expected = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4]])
torch.testing.assert_close(result[TransitionKey.OBSERVATION][OBS_STATE], expected, atol=1e-6, rtol=1e-6)
def test_state_history_can_use_se3_6d_rotation_with_absolute_gripper():
state_history = torch.tensor(
[[[0.0, 1.0, 0.0, 0.0, 0.0, pi / 2, 0.2], [0.0, 0.0, 0.0, 0.0, 0.0, pi / 2, 0.4]]]
)
step = Pi05FlattenStateHistoryProcessorStep(
history_steps=2,
max_state_dim=20,
relative=True,
exclude_joints=["gripper"],
state_names=["x", "y", "z", "rx", "ry", "rz", "gripper_width"],
pose_representation="se3_6d",
se3_pose_groups=[list(range(6))],
)
result = step(_transition(torch.zeros(1, 2, 7), state_history))
identity_6d = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
expected = torch.tensor([[1.0, 0.0, 0.0, *identity_6d, 0.2, 0.0, 0.0, 0.0, *identity_6d, 0.4]])
torch.testing.assert_close(result[TransitionKey.OBSERVATION][OBS_STATE], expected, atol=1e-6, rtol=1e-6)
def test_inference_state_history_is_rolled_and_reset():
step = Pi05StateFromActionProcessorStep(enabled=True, history_steps=2)
first = step(_transition(None, torch.tensor([[1.0, 2.0]])))
second = step(_transition(None, torch.tensor([[3.0, 4.0]])))
torch.testing.assert_close(
first[TransitionKey.OBSERVATION][OBS_STATE], torch.tensor([[[1.0, 2.0], [1.0, 2.0]]])
)
torch.testing.assert_close(
second[TransitionKey.OBSERVATION][OBS_STATE], torch.tensor([[[1.0, 2.0], [3.0, 4.0]]])
)
step.reset()
reset = step(_transition(None, torch.tensor([[5.0, 6.0]])))
torch.testing.assert_close(
reset[TransitionKey.OBSERVATION][OBS_STATE], torch.tensor([[[5.0, 6.0], [5.0, 6.0]]])
)
def test_flatten_state_history_checks_max_state_dim():
step = Pi05FlattenStateHistoryProcessorStep(history_steps=2, max_state_dim=3)
with pytest.raises(ValueError, match="above max_state_dim"):
step(_transition(torch.zeros(1, 2, 2), torch.zeros(1, 2, 2)))
def test_relative_stats_can_use_absolute_action_as_state():
actions = np.asarray([[0.0, 0.0], [1.0, 2.0], [2.0, 4.0], [3.0, 6.0]], dtype=np.float32)
dataset = {"action": actions, "episode_index": np.zeros(4, dtype=np.int64)}
features = {"action": {"shape": [2], "names": ["x", "y"]}}
stats = compute_relative_action_stats(
dataset,
features,
chunk_size=2,
state_from_action=True,
)
np.testing.assert_allclose(stats["mean"], [0.5, 1.0])
def test_relative_state_history_stats_match_processor_representation():
actions = np.asarray(
[[0.0, 0.1], [1.0, 0.2], [3.0, 0.3]],
dtype=np.float32,
)
dataset = {"action": actions, "episode_index": np.zeros(3, dtype=np.int64)}
features = {"action": {"shape": [2], "names": ["x", "gripper_width"]}}
stats = compute_state_history_stats(
dataset,
features,
history_steps=2,
exclude_joints=["gripper"],
relative=True,
)
expected = np.asarray([[0.0, 0.1, 0.0, 0.1], [-1.0, 0.1, 0.0, 0.2], [-2.0, 0.2, 0.0, 0.3]])
np.testing.assert_allclose(stats["mean"], expected.mean(axis=0))
def test_se3_relative_action_stats_use_reference_frame():
actions = np.asarray(
[
[0.0, 0.0, 0.0, 0.0, 0.0, pi / 2, 0.2],
[0.0, 1.0, 0.0, 0.0, 0.0, pi / 2, 0.3],
],
dtype=np.float32,
)
dataset = {"action": actions, "episode_index": np.zeros(2, dtype=np.int64)}
features = {
"action": {
"shape": [7],
"names": ["x", "y", "z", "rx", "ry", "rz", "gripper_width"],
}
}
stats = compute_relative_action_stats(
dataset,
features,
chunk_size=2,
exclude_joints=["gripper"],
state_from_action=True,
pose_representation="se3",
se3_pose_groups=[list(range(6))],
)
np.testing.assert_allclose(stats["mean"][:3], [0.5, 0.0, 0.0], atol=1e-6)
np.testing.assert_allclose(stats["mean"][6], 0.25, atol=1e-6)
def test_se3_6d_stats_expand_action_and_state_history():
actions = np.asarray(
[
[0.0, 0.0, 0.0, 0.0, 0.0, pi / 2, 0.2],
[0.0, 1.0, 0.0, 0.0, 0.0, pi / 2, 0.3],
],
dtype=np.float32,
)
dataset = {"action": actions, "episode_index": np.zeros(2, dtype=np.int64)}
features = {
"action": {
"shape": [7],
"names": ["x", "y", "z", "rx", "ry", "rz", "gripper_width"],
}
}
action_stats = compute_relative_action_stats(
dataset,
features,
chunk_size=2,
exclude_joints=["gripper"],
state_from_action=True,
pose_representation="se3_6d",
se3_pose_groups=[list(range(6))],
)
state_stats = compute_state_history_stats(
dataset,
features,
history_steps=2,
exclude_joints=["gripper"],
relative=True,
pose_representation="se3_6d",
se3_pose_groups=[list(range(6))],
)
assert action_stats["mean"].shape == (10,)
assert state_stats["mean"].shape == (20,)
np.testing.assert_allclose(action_stats["mean"][:3], [0.5, 0.0, 0.0], atol=1e-6)
np.testing.assert_allclose(action_stats["mean"][9], 0.25, atol=1e-6)
@@ -0,0 +1,144 @@
import math
import pytest
import torch
from lerobot.processor.relative_action_processor import (
rotation_6d_to_rotvec,
rotvec_to_rotation_6d,
to_absolute_actions,
to_absolute_se3_pose,
to_absolute_se3_pose_6d,
to_relative_actions,
to_relative_se3_pose,
to_relative_se3_pose_6d,
)
POSE_GROUP = [list(range(6))]
def test_se3_translation_is_expressed_in_reference_frame():
reference = torch.tensor([[1.0, 2.0, 3.0, 0.0, 0.0, math.pi / 2]])
target = torch.tensor([[1.0, 3.0, 3.0, 0.0, 0.0, math.pi / 2]])
relative = to_relative_se3_pose(target, reference)
torch.testing.assert_close(relative, torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]]), atol=1e-6, rtol=1e-6)
def test_se3_pose_roundtrip_for_batched_chunks():
torch.manual_seed(0)
reference = torch.randn(4, 6)
reference[:, 3:] *= 0.8
target = torch.randn(4, 11, 6)
target[..., 3:] *= 0.8
relative = to_relative_se3_pose(target, reference.unsqueeze(1))
recovered = to_absolute_se3_pose(relative, reference.unsqueeze(1))
torch.testing.assert_close(recovered, target, atol=2e-5, rtol=2e-5)
def test_mixed_se3_pose_and_absolute_gripper_roundtrip():
reference = torch.tensor([[0.2, -0.1, 0.4, 0.1, 0.2, -0.3, 0.06]])
target = torch.tensor([[[0.3, 0.2, 0.5, -0.2, 0.1, 0.4, 0.03], [0.1, -0.3, 0.2, 0.5, -0.1, 0.2, 0.05]]])
mask = [True, True, True, True, True, True, False]
relative = to_relative_actions(
target,
reference,
mask,
pose_representation="se3",
se3_pose_groups=POSE_GROUP,
)
recovered = to_absolute_actions(
relative,
reference,
mask,
pose_representation="se3",
se3_pose_groups=POSE_GROUP,
)
torch.testing.assert_close(relative[..., 6], target[..., 6])
torch.testing.assert_close(recovered, target, atol=2e-5, rtol=2e-5)
def test_se3_pose_group_cannot_be_partially_relative():
with pytest.raises(ValueError, match="wholly relative or wholly absolute"):
to_relative_actions(
torch.zeros(1, 7),
torch.zeros(1, 7),
[True, True, True, False, False, False, False],
pose_representation="se3",
se3_pose_groups=POSE_GROUP,
)
@pytest.mark.parametrize(
"rotvec",
[
[0.0, 0.0, 0.0],
[0.2, -0.5, 0.8],
[math.pi - 1e-4, 0.0, 0.0],
],
)
def test_rotation_6d_roundtrip(rotvec):
source = torch.tensor([rotvec], dtype=torch.float64)
recovered = rotation_6d_to_rotvec(rotvec_to_rotation_6d(source))
torch.testing.assert_close(recovered, source, atol=2e-6, rtol=2e-6)
def test_rotation_6d_uses_umi_first_two_rows():
source = torch.tensor([[0.0, 0.0, math.pi / 2]], dtype=torch.float64)
encoded = rotvec_to_rotation_6d(source)
expected = torch.tensor([[0.0, -1.0, 0.0, 1.0, 0.0, 0.0]], dtype=torch.float64)
torch.testing.assert_close(encoded, expected, atol=1e-7, rtol=1e-7)
torch.testing.assert_close(rotation_6d_to_rotvec(expected), source, atol=1e-7, rtol=1e-7)
def test_se3_6d_pose_roundtrip_for_batched_chunks():
torch.manual_seed(1)
reference = torch.randn(4, 6)
reference[:, 3:] *= 0.8
target = torch.randn(4, 11, 6)
target[..., 3:] *= 0.8
relative = to_relative_se3_pose_6d(target, reference.unsqueeze(1))
recovered = to_absolute_se3_pose_6d(relative, reference.unsqueeze(1))
assert relative.shape == (4, 11, 9)
torch.testing.assert_close(recovered, target, atol=2e-5, rtol=2e-5)
def test_mixed_se3_6d_pose_and_absolute_gripper_roundtrip():
reference = torch.tensor([[0.2, -0.1, 0.4, 0.1, 0.2, -0.3, 0.06]])
target = torch.tensor([[[0.3, 0.2, 0.5, -0.2, 0.1, 0.4, 0.03], [0.1, -0.3, 0.2, 0.5, -0.1, 0.2, 0.05]]])
mask = [True, True, True, True, True, True, False]
relative = to_relative_actions(
target,
reference,
mask,
pose_representation="se3_6d",
se3_pose_groups=POSE_GROUP,
)
recovered = to_absolute_actions(
relative,
reference,
mask,
pose_representation="se3_6d",
se3_pose_groups=POSE_GROUP,
)
assert relative.shape == (1, 2, 10)
torch.testing.assert_close(relative[..., 9], target[..., 6])
torch.testing.assert_close(recovered, target, atol=2e-5, rtol=2e-5)
def test_rotation_6d_rejects_degenerate_prediction():
with pytest.raises(ValueError, match="degenerate"):
rotation_6d_to_rotvec(torch.zeros(1, 6))
+126
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import dataclasses
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
@@ -98,6 +99,131 @@ def test_inference_config_types():
assert rtc.rtc is not None
def test_trained_rtc_retries_chunk_when_measured_delay_exceeds_conditioning():
from lerobot.rollout.inference.rtc import _trained_rtc_chunk_can_merge
assert not _trained_rtc_chunk_can_merge(
conditioned_delay=2,
measured_delay=3,
training_max_delay=4,
has_previous_actions=True,
)
assert _trained_rtc_chunk_can_merge(
conditioned_delay=2,
measured_delay=5,
training_max_delay=4,
has_previous_actions=False,
)
def test_trained_rtc_bootstraps_first_overlap_with_checkpoint_capacity():
from lerobot.rollout.inference.rtc import _estimate_rtc_delay
assert (
_estimate_rtc_delay(
latency=0,
time_per_step=1 / 30,
mode="trained",
training_max_delay=10,
has_previous_actions=False,
)
== 0
)
assert (
_estimate_rtc_delay(
latency=0,
time_per_step=1 / 30,
mode="trained",
training_max_delay=10,
has_previous_actions=True,
)
== 10
)
def test_trained_rtc_rejects_measured_delay_above_checkpoint_support():
from lerobot.rollout.inference.rtc import (
_trained_rtc_chunk_can_merge,
_TrainedRTCDelayExceededError,
)
with pytest.raises(_TrainedRTCDelayExceededError, match="rtc_training_max_delay"):
_trained_rtc_chunk_can_merge(
conditioned_delay=3,
measured_delay=5,
training_max_delay=4,
has_previous_actions=True,
)
def test_trained_rtc_rejects_prefix_shorter_than_conditioned_delay():
from lerobot.rollout.inference.rtc import (
_TrainedRTCPrefixUnavailableError,
_validate_trained_rtc_prefix_available,
)
with pytest.raises(_TrainedRTCPrefixUnavailableError, match="only 2"):
_validate_trained_rtc_prefix_available(conditioned_delay=4, available_steps=2)
@pytest.mark.parametrize(
("execution_horizon", "queue_threshold", "match"),
[
(3, 4, "execution_horizon"),
(4, 3, "queue_threshold"),
],
)
def test_trained_rtc_rollout_requires_capacity_for_max_delay(execution_horizon, queue_threshold, match):
from lerobot.policies.rtc.configuration_rtc import RTCConfig
from lerobot.rollout.context import _validate_trained_rtc_rollout_config
from lerobot.rollout.inference import RTCInferenceConfig
policy_config = SimpleNamespace(type="pi05", rtc_training_max_delay=4)
inference_config = RTCInferenceConfig(
rtc=RTCConfig(mode="trained", execution_horizon=execution_horizon),
queue_threshold=queue_threshold,
)
with pytest.raises(ValueError, match=match):
_validate_trained_rtc_rollout_config(policy_config, inference_config)
def test_relative_state_order_follows_checkpoint_action_names():
from lerobot.rollout.context import _align_relative_state_feature_order
from lerobot.utils.constants import OBS_STATE
from lerobot.utils.feature_utils import build_dataset_frame
hw_features = {
OBS_STATE: {
"dtype": "float32",
"shape": (4,),
"names": ["left_joint.pos", "left_gripper.pos", "right_joint.pos", "right_gripper.pos"],
}
}
checkpoint_order = [
"right_joint.pos",
"right_gripper.pos",
"left_joint.pos",
"left_gripper.pos",
]
aligned = _align_relative_state_feature_order(hw_features, checkpoint_order)
frame = build_dataset_frame(
aligned,
{
"left_joint.pos": 1.0,
"left_gripper.pos": 2.0,
"right_joint.pos": 3.0,
"right_gripper.pos": 4.0,
},
prefix="observation",
)
assert aligned[OBS_STATE]["names"] == checkpoint_order
assert frame[OBS_STATE].tolist() == [3.0, 4.0, 1.0, 2.0]
assert hw_features[OBS_STATE]["names"][0] == "left_joint.pos"
def test_sentry_config_defaults():
from lerobot.rollout import SentryStrategyConfig