Skip to content

Commit 00853b3

Browse files
committed
Add support for training and inference to work with IHMC systems.
1 parent 7fe6ada commit 00853b3

8 files changed

Lines changed: 501 additions & 28 deletions

File tree

README_IHMC.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Notes for IHMC workflow
2+
3+
See the below notes for running training and inference.
4+
Since for inference we require ROS 2 and we don't know how to build all the dependencies for training
5+
when we include ROS 2 (via Robostack), we have two separate conda environments for training and inference.
6+
7+
## Dataset creation
8+
9+
Datasets are generated from IHMC logs using a robot specific app such as `H1RDXSCS2LogVisualizer`.
10+
The result will be a dataset folder in the log directory that contains `meta/info.json` and more.
11+
12+
## Training
13+
14+
Setup your SSH config (`~/.ssh/config`) to allow the simple ssh and rsync commands to work:
15+
```
16+
Host gpu2
17+
HostName gpu2.ihmc.us
18+
User <username>
19+
ForwardAgent yes
20+
```
21+
22+
Make sure you can login:
23+
```
24+
$ ssh gpu2
25+
```
26+
27+
### Uploading your dataset
28+
29+
Make sure there's a `datasets` folder in your user home on gpu2:
30+
```
31+
gpu2:~ $ mkdir -p datasets
32+
```
33+
34+
Back on your machine, use rsync to upload your dataset to the server:
35+
```
36+
dataset $ rsync -avz --exclude='.git' "$PWD" gpu2:~/datasets/
37+
```
38+
39+
### Running training
40+
41+
Copy your locally cloned lerobot repo on the IHMC `ros2rebase` branch to the gpu server:
42+
```
43+
lerobot $ rsync -avz --exclude='.git' "$PWD" gpu2:~
44+
```
45+
46+
Use tmux in order to train overnight without needing to leave a terminal open.
47+
48+
```
49+
$ tmux ls // list sessions to attach to
50+
$ tmux new -s lerobot // create a new session
51+
```
52+
To detach, press Ctrl+B, then D.
53+
54+
Build and run the Docker container:
55+
```
56+
~/lerobot/docker/lerobot-ihmc $ ./run.sh
57+
```
58+
59+
Activate the conda environment:
60+
```
61+
$ conda activate lerobot
62+
```
63+
64+
`cd` into a dataset and run the training:
65+
```
66+
/datasets/dataset $ python /lerobot/src/lerobot/scripts/train.py \
67+
--dataset.repo_id=robotlab"$(pwd)" \
68+
--dataset.root="$(pwd)" \
69+
--policy.push_to_hub=false \
70+
--policy.type=diffusion \
71+
--output_dir=outputs/train"$(pwd)"
72+
```
73+
74+
Pass the `--resume=true` to continue from a previous run if needed.
75+
76+
After training has finished, on your computer, copy the trained model back into your dataset folder:
77+
```
78+
dataset $ scp -r gpu2:~/datasets/$(basename "$PWD")/outputs/train/datasets/$(basename "$PWD")/checkpoints/last .
79+
```
80+
81+
## Inference
82+
83+
### Install mamba
84+
85+
1. Install [miniconda](https://www.anaconda.com/docs/getting-started/miniconda/main).
86+
```
87+
$ curl -O https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
88+
$ chmod +x Miniconda3-latest-*.sh
89+
$ ./Miniconda3-latest-*.sh
90+
```
91+
```
92+
$ conda init
93+
```
94+
Close and re-open terminal
95+
96+
2. Install [mamba](https://mamba.readthedocs.io/en/latest/installation/mamba-installation.html).
97+
```
98+
$ conda install mamba -c conda-forge
99+
```
100+
101+
I needed to setup the shell and restart it:
102+
```
103+
$ mamba shell init --shell bash --root-prefix=~/.local/share/mamba
104+
```
105+
106+
For some reason on Arch Linux I needed to attain more permissions:
107+
```
108+
# chown -R duncan:duncan /opt/miniconda3/
109+
```
110+
111+
### Create mamba environment with ROS 2
112+
113+
```
114+
$ mamba env create -f ros2env.yaml
115+
$ mamba activate lerobot_ros2
116+
```
117+
Make sure you can run `rviz2`.
118+
```
119+
$ rviz2
120+
```
121+
122+
Setup the lerobot repo, skipping the dependencies:
123+
```
124+
lerobot $ pip install -e . --no-deps
125+
```
126+
127+
For communicating with IHMC ROS 2 nodes, make sure to set `RTPSSubnet=0.0.0.0/8` in your `~/.ihmc/IHMCNetworkParameters.ini`.
128+
129+
Run the policy:
130+
```
131+
$ python src/lerobot/scripts/run_inference_ihmc.py --policy=/path/to/dataset/last/pretrained_model/
132+
```
133+

docker/lerobot-ihmc/Dockerfile

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
FROM nvidia/cuda:12.1.1-cudnn8-devel-ubuntu22.04
2+
3+
ARG DEBIAN_FRONTEND=noninteractive
4+
5+
RUN apt-get update && apt-get install -y \
6+
build-essential \
7+
curl \
8+
zip \
9+
unzip \
10+
wget \
11+
git \
12+
cmake \
13+
apt-transport-https \
14+
iputils-ping \
15+
ca-certificates \
16+
software-properties-common \
17+
python3-opencv \
18+
iproute2 \
19+
libglib2.0-0 libgl1-mesa-glx libegl1-mesa ffmpeg \
20+
speech-dispatcher libgeos-dev \
21+
&& apt-get clean \
22+
&& rm -rf /var/lib/apt/lists/*
23+
24+
# Download and install Miniconda
25+
ENV CONDA_DIR=/opt/conda
26+
RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.sh \
27+
&& /bin/bash ~/miniconda.sh -b -p $CONDA_DIR \
28+
&& rm ~/miniconda.sh
29+
ENV PATH=$CONDA_DIR/bin:$PATH
30+
31+
RUN conda init bash
32+
33+
RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main \
34+
&& conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r \
35+
&& conda update -n base conda -y \
36+
&& conda clean --all --yes
37+
38+
RUN conda create -y -n lerobot python=3.10
39+
40+
RUN conda run -n lerobot conda install ffmpeg -c conda-forge
41+
42+
COPY . lerobot
43+
WORKDIR /lerobot
44+
45+
RUN conda run -n lerobot pip install -e ".[aloha, pusht]"
46+
47+
RUN echo 'alias cal="conda activate lerobot"' >> ~/.bashrc
48+
49+
WORKDIR /datasets

docker/lerobot-ihmc/run.sh

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
# Immediately exit on any errors.
3+
set -e
4+
# Print commands as they are run.
5+
set -o xtrace
6+
7+
cd ../..
8+
9+
docker build --tag ihmcrobotics/lerobot-ihmc:0.2 --file docker/lerobot-ihmc/Dockerfile .
10+
11+
docker run \
12+
--tty \
13+
--interactive \
14+
--rm \
15+
--dns=1.1.1.1 \
16+
--env "TERM=xterm-256color" `# Enable color in the terminal` \
17+
--privileged \
18+
--gpus all \
19+
--shm-size=20g \
20+
--volume /home/$USER/lerobot:/lerobot \
21+
--volume /home/$USER/datasets:/datasets \
22+
ihmcrobotics/lerobot-ihmc:0.2 bash

ros2env.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: lerobot_ros2
2+
channels:
3+
- nvidia
4+
- conda-forge
5+
- robostack-staging
6+
dependencies:
7+
- ros-humble-desktop
8+
- cmake>=3.29.0.1
9+
- einops>=0.8.0
10+
- opencv>=4.9.0
11+
- av>=14.2.0
12+
- jsonlines>=4.0.0
13+
- packaging>=24.2
14+
- pynput>=1.7.7
15+
- pyserial>=3.5
16+
- wandb>=0.20.0
17+
- gymnasium>=0.29.1,<1.0.0
18+
- flask>=3.0.3,<4.0.0
19+
- imageio>=2.34.0,<3.0.0
20+
- imageio-ffmpeg
21+
- termcolor>=2.4.0,<4.0.0
22+
- datasets>=2.19.0,<=3.6.0
23+
- diffusers>=0.27.2,<0.34.0
24+
- rerun-sdk>=0.21.0,<0.23.0
25+
- deepdiff>=7.0.1,<9.0.0
26+
- pip:
27+
- torch==2.7.1 --index-url https://download.pytorch.org/whl/cu128 # Support 50 series cards
28+
- torchvision --index-url https://download.pytorch.org/whl/cu128
29+
- torchaudio --index-url https://download.pytorch.org/whl/cu128
30+
- torchcodec --index-url https://download.pytorch.org/whl/cu128
31+
- huggingface-hub[hf-transfer,cli]>=0.27.1,<0.34.0
32+
- draccus==0.10.0

src/lerobot/datasets/compute_stats.py

Lines changed: 122 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -122,35 +122,130 @@ def _assert_type_and_shape(stats_list: list[dict[str, dict]]):
122122
if "image" in fkey and k != "count" and v.shape != (3, 1, 1):
123123
raise ValueError(f"Shape of '{k}' must be (3,1,1), but is {v.shape} instead.")
124124

125-
126-
def aggregate_feature_stats(stats_ft_list: list[dict[str, dict]]) -> dict[str, dict[str, np.ndarray]]:
127-
"""Aggregates stats for a single feature."""
128-
means = np.stack([s["mean"] for s in stats_ft_list])
129-
variances = np.stack([s["std"] ** 2 for s in stats_ft_list])
130-
counts = np.stack([s["count"] for s in stats_ft_list])
131-
total_count = counts.sum(axis=0)
132-
133-
# Prepare weighted mean by matching number of dimensions
134-
while counts.ndim < means.ndim:
135-
counts = np.expand_dims(counts, axis=-1)
136-
137-
# Compute the weighted mean
138-
weighted_means = means * counts
139-
total_mean = weighted_means.sum(axis=0) / total_count
140-
141-
# Compute the variance using the parallel algorithm
142-
delta_means = means - total_mean
143-
weighted_variances = (variances + delta_means**2) * counts
144-
total_variance = weighted_variances.sum(axis=0) / total_count
145-
146-
return {
147-
"min": np.min(np.stack([s["min"] for s in stats_ft_list]), axis=0),
148-
"max": np.max(np.stack([s["max"] for s in stats_ft_list]), axis=0),
149-
"mean": total_mean,
150-
"std": np.sqrt(total_variance),
151-
"count": total_count,
125+
def aggregate_feature_stats(stats_ft_list):
126+
"""
127+
Combine per-episode stats for a single feature.
128+
129+
Each element of stats_ft_list must be a dict with:
130+
- 'mean': array-like
131+
- 'std': array-like
132+
- 'count' or 'n': int
133+
Optional:
134+
- 'min': array-like
135+
- 'max': array-like
136+
137+
Returns:
138+
{
139+
'mean': (D,) float64
140+
'std': (D,) float64
141+
'count': int
142+
# 'min': (D,) float64 (if present in any input)
143+
# 'max': (D,) float64 (if present in any input)
144+
}
145+
"""
146+
def _norm(x, name, idx):
147+
try:
148+
arr = np.asarray(x, dtype=np.float64)
149+
except Exception as e:
150+
raise TypeError(f"{name} at idx {idx} could not be converted to float64: {e}")
151+
arr = np.squeeze(arr)
152+
if arr.ndim == 0:
153+
arr = arr[None]
154+
return arr
155+
156+
means, vars_, counts = [], [], []
157+
mins, maxs = [], []
158+
have_min = False
159+
have_max = False
160+
161+
for i, s in enumerate(stats_ft_list):
162+
if s is None:
163+
continue
164+
165+
if "mean" not in s or ("std" not in s):
166+
raise KeyError(f"Missing 'mean' or 'std' at idx {i}: keys={list(s.keys())}")
167+
168+
m = _norm(s["mean"], "mean", i)
169+
sd = _norm(s["std"], "std", i)
170+
171+
# count key tolerance
172+
n = s.get("count", s.get("n", None))
173+
if n is None:
174+
raise KeyError(f"Missing 'count' (or 'n') at idx {i}")
175+
try:
176+
n = int(n)
177+
except Exception:
178+
raise TypeError(f"'count' must be int-like at idx {i}, got {type(n)}: {n}")
179+
180+
if m.shape != sd.shape:
181+
raise ValueError(f"mean/std shape mismatch at idx {i}: {m.shape} vs {sd.shape}")
182+
183+
means.append(m)
184+
vars_.append(sd ** 2)
185+
counts.append(n)
186+
187+
if "min" in s and s["min"] is not None:
188+
mins.append(_norm(s["min"], "min", i))
189+
have_min = True
190+
if "max" in s and s["max"] is not None:
191+
maxs.append(_norm(s["max"], "max", i))
192+
have_max = True
193+
194+
if not means:
195+
raise ValueError("No valid stats provided.")
196+
197+
# Ensure all episodes have the same feature dimension
198+
shapes = {tuple(x.shape) for x in means}
199+
if len(shapes) != 1:
200+
raise ValueError(f"Inconsistent feature shapes across episodes: {shapes}. Fix upstream.")
201+
202+
means = np.stack(means) # (E, D)
203+
vars_ = np.stack(vars_) # (E, D)
204+
counts = np.asarray(counts, dtype=np.int64) # (E,)
205+
206+
N = int(counts.sum())
207+
if N <= 1:
208+
# Degenerate case; fall back to simple average/std
209+
pooled_mean = means.mean(axis=0)
210+
pooled_std = np.sqrt(vars_.mean(axis=0))
211+
else:
212+
# Weighted (pooled) mean
213+
w = counts[:, None] # (E, 1)
214+
pooled_mean = (w * means).sum(axis=0) / N # (D,)
215+
216+
# Unbiased pooled variance:
217+
# SS_within = sum_i (n_i - 1) * var_i
218+
# SS_between = sum_i n_i * (mean_i - pooled_mean)^2
219+
ss_within = ((counts - 1)[:, None] * vars_).sum(axis=0)
220+
ss_between = (w * (means - pooled_mean) ** 2).sum(axis=0)
221+
denom = max(N - 1, 1)
222+
pooled_var = (ss_within + ss_between) / denom
223+
pooled_std = np.sqrt(np.maximum(pooled_var, 0.0))
224+
225+
out = {
226+
"mean": pooled_mean.astype(np.float64),
227+
"std": pooled_std.astype(np.float64),
228+
"count": N,
152229
}
153230

231+
# Optional min/max aggregation (elementwise)
232+
if have_min:
233+
# if some episodes lacked min/max, we only use the ones that exist
234+
mins = [m for m in mins if m is not None]
235+
# validate shapes
236+
shapes = {tuple(np.squeeze(np.asarray(m)).shape) for m in mins}
237+
if len(shapes) != 1:
238+
raise ValueError(f"Inconsistent 'min' shapes across episodes: {shapes}.")
239+
out["min"] = np.min(np.stack(mins), axis=0).astype(np.float64)
240+
241+
if have_max:
242+
maxs = [m for m in maxs if m is not None]
243+
shapes = {tuple(np.squeeze(np.asarray(m)).shape) for m in maxs}
244+
if len(shapes) != 1:
245+
raise ValueError(f"Inconsistent 'max' shapes across episodes: {shapes}.")
246+
out["max"] = np.max(np.stack(maxs), axis=0).astype(np.float64)
247+
248+
return out
154249

155250
def aggregate_stats(stats_list: list[dict[str, dict]]) -> dict[str, dict[str, np.ndarray]]:
156251
"""Aggregate stats from multiple compute_stats outputs into a single set of stats.

0 commit comments

Comments
 (0)