arminfg commited on
Commit
5c758be
·
verified ·
1 Parent(s): b5c86a6

Train on real Himalayan (Khumbu SRTM) terrain: per-env heightfield crops

Browse files
Files changed (5) hide show
  1. README.md +12 -3
  2. app.py +26 -4
  3. dem_khumbu.npy +3 -0
  4. himalaya_terrain.py +111 -0
  5. requirements.txt +1 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: G1 Rough Terrain Training
3
  emoji: 🦿
4
  colorFrom: red
5
  colorTo: green
@@ -8,10 +8,19 @@ sdk_version: 6.26.0
8
  python_version: '3.11'
9
  app_file: app.py
10
  pinned: false
11
- short_description: GPU training for Unitree G1 rough-terrain locomotion
12
  ---
13
 
14
- # Unitree G1 — rough-terrain locomotion training
 
 
 
 
 
 
 
 
 
15
 
16
  Trains `G1JoystickRoughTerrain` from [MuJoCo Playground](https://playground.mujoco.org/)
17
  with Brax PPO on the Space's GPU. Thousands of MJX environments step in parallel,
 
1
  ---
2
+ title: G1 Himalaya Terrain Training
3
  emoji: 🦿
4
  colorFrom: red
5
  colorTo: green
 
8
  python_version: '3.11'
9
  app_file: app.py
10
  pinned: false
11
+ short_description: GPU training for Unitree G1 on real Himalayan terrain
12
  ---
13
 
14
+ # Unitree G1 — Himalayan terrain locomotion training
15
+
16
+ Trains on **real elevation data**: `dem_khumbu.npy` is SRTM-derived terrain
17
+ (AWS Terrain Tiles, 4.2 m/px, 3.2 km square) of the Khumbu glacier valley below
18
+ Everest Base Camp. `himalaya_terrain.py` cuts random 600 m crops, squeezes them
19
+ onto Playground's 20 m arena with 0.3 m of relief plus scree detail, and gives
20
+ every environment its own crop through the domain randomizer. Set `TERRAIN=playground`
21
+ to fall back to the stock 5 cm noise field; `HIMALAYA_RELIEF`, `HIMALAYA_PATCH`,
22
+ `NUM_TERRAINS` tune the terrain.
23
+
24
 
25
  Trains `G1JoystickRoughTerrain` from [MuJoCo Playground](https://playground.mujoco.org/)
26
  with Brax PPO on the Space's GPU. Thousands of MJX environments step in parallel,
app.py CHANGED
@@ -24,6 +24,14 @@ HF_REPO = os.environ.get("HF_REPO", "").strip()
24
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() or None
25
  AUTO_START = os.environ.get("AUTO_START", "1") == "1"
26
 
 
 
 
 
 
 
 
 
27
  # /data exists only when persistent storage is attached; fall back to /tmp.
28
  OUT = Path("/data/ckpt") if Path("/data").is_dir() else Path("/tmp/ckpt")
29
 
@@ -153,6 +161,20 @@ def train_worker() -> None:
153
  log(f"loading {ENV_NAME} ...")
154
  env = registry.load(ENV_NAME)
155
  env_cfg = registry.get_default_config(ENV_NAME)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  ppo_params = locomotion_params.brax_ppo_config(ENV_NAME)
157
  ppo_params.num_timesteps = int(STATE["target"])
158
  log(f"num_envs={ppo_params.get('num_envs')} "
@@ -196,9 +218,9 @@ def train_worker() -> None:
196
  _, params, _ = ppo.train(
197
  **ppo_kwargs,
198
  environment=env,
199
- eval_env=registry.load(ENV_NAME, config=env_cfg),
200
  wrap_env_fn=wrapper.wrap_for_brax_training,
201
- randomization_fn=registry.get_domain_randomizer(ENV_NAME),
202
  progress_fn=progress,
203
  policy_params_fn=policy_params_fn,
204
  seed=SEED,
@@ -244,7 +266,7 @@ def status_md() -> str:
244
  alive = STATE["thread"] is not None and STATE["thread"].is_alive()
245
  target = int(STATE["target"])
246
  pct = 100.0 * STATE["step"] / max(target, 1)
247
- return (f"**{ENV_NAME}** — status: `{STATE['status']}`"
248
  f"{' (running)' if alive else ''} \n"
249
  f"step {STATE['step']:,} / {target:,} ({pct:.1f}%) \n"
250
  f"checkpoints: `{OUT}`"
@@ -257,7 +279,7 @@ def logs() -> str:
257
 
258
 
259
  with gr.Blocks(title="G1 rough-terrain training") as demo:
260
- gr.Markdown("# Unitree G1 — rough-terrain locomotion training")
261
  st = gr.Markdown(status_md())
262
  with gr.Row():
263
  steps_in = gr.Number(value=NUM_TIMESTEPS, precision=0, label="timesteps",
 
24
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() or None
25
  AUTO_START = os.environ.get("AUTO_START", "1") == "1"
26
 
27
+ # Terrain: "himalaya" swaps Playground's 5 cm noise heightfield for crops of real
28
+ # SRTM elevation of the Khumbu valley (see himalaya_terrain.py); "playground"
29
+ # keeps the stock terrain. Every env trains on its own crop.
30
+ TERRAIN = os.environ.get("TERRAIN", "himalaya")
31
+ NUM_TERRAINS = int(os.environ.get("NUM_TERRAINS", 64))
32
+ HIMALAYA_RELIEF = float(os.environ.get("HIMALAYA_RELIEF", 0.3)) # metres over the 20 m arena
33
+ HIMALAYA_PATCH = float(os.environ.get("HIMALAYA_PATCH", 600.0)) # metres of real ground per arena
34
+
35
  # /data exists only when persistent storage is attached; fall back to /tmp.
36
  OUT = Path("/data/ckpt") if Path("/data").is_dir() else Path("/tmp/ckpt")
37
 
 
161
  log(f"loading {ENV_NAME} ...")
162
  env = registry.load(ENV_NAME)
163
  env_cfg = registry.get_default_config(ENV_NAME)
164
+ eval_env = registry.load(ENV_NAME, config=env_cfg)
165
+ randomization_fn = registry.get_domain_randomizer(ENV_NAME)
166
+ if TERRAIN == "himalaya":
167
+ from himalaya_terrain import apply_terrain, make_terrains, randomizer
168
+ log(f"building {NUM_TERRAINS} Himalaya crops: {HIMALAYA_PATCH:.0f} m of Khumbu "
169
+ f"-> 20 m arena, relief {HIMALAYA_RELIEF} m ...")
170
+ grids = make_terrains(NUM_TERRAINS, seed=SEED, patch_m=HIMALAYA_PATCH,
171
+ relief=HIMALAYA_RELIEF)
172
+ apply_terrain(env, grids, HIMALAYA_RELIEF)
173
+ apply_terrain(eval_env, grids, HIMALAYA_RELIEF)
174
+ randomization_fn = randomizer(randomization_fn, grids)
175
+ log("terrain: himalaya (per-env crops via domain randomization)")
176
+ else:
177
+ log("terrain: playground stock rough terrain")
178
  ppo_params = locomotion_params.brax_ppo_config(ENV_NAME)
179
  ppo_params.num_timesteps = int(STATE["target"])
180
  log(f"num_envs={ppo_params.get('num_envs')} "
 
218
  _, params, _ = ppo.train(
219
  **ppo_kwargs,
220
  environment=env,
221
+ eval_env=eval_env,
222
  wrap_env_fn=wrapper.wrap_for_brax_training,
223
+ randomization_fn=randomization_fn,
224
  progress_fn=progress,
225
  policy_params_fn=policy_params_fn,
226
  seed=SEED,
 
266
  alive = STATE["thread"] is not None and STATE["thread"].is_alive()
267
  target = int(STATE["target"])
268
  pct = 100.0 * STATE["step"] / max(target, 1)
269
+ return (f"**{ENV_NAME}** · terrain `{TERRAIN}` — status: `{STATE['status']}`"
270
  f"{' (running)' if alive else ''} \n"
271
  f"step {STATE['step']:,} / {target:,} ({pct:.1f}%) \n"
272
  f"checkpoints: `{OUT}`"
 
279
 
280
 
281
  with gr.Blocks(title="G1 rough-terrain training") as demo:
282
+ gr.Markdown("# Unitree G1 — Himalayan terrain locomotion training")
283
  st = gr.Markdown(status_md())
284
  with gr.Row():
285
  steps_in = gr.Number(value=NUM_TIMESTEPS, precision=0, label="timesteps",
dem_khumbu.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a9f73e91d7ee40949349f7ea4b6a4e035ebc4a97d257a70921e498bd5136548
3
+ size 2359424
himalaya_terrain.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Himalayan heightfields for MuJoCo Playground's G1JoystickRoughTerrain.
2
+
3
+ `dem_khumbu.npy` is real SRTM-derived elevation (AWS Terrain Tiles, zoom 15,
4
+ 4.2 m/px, 3.2 km square) of the Khumbu glacier valley below Everest Base Camp,
5
+ generated by examples/g1_himalaya.py in the mujoco-mcp-server repo.
6
+
7
+ Playground's rough-terrain scene is a 256x256 heightfield over a 20 m arena.
8
+ A real valley is not walkable at 1:1 by a humanoid, so `patch_m` metres of real
9
+ ground are squeezed onto the arena and the relief normalized to `relief` metres.
10
+ Each crop is centred so the ground under the spawn sits at data value 0.5 --
11
+ the floor geom is lowered by relief/2 to put that at z = 0 -- and a 1 m disc
12
+ around the spawn is levelled (Playground jitters the spawn by +-0.5 m).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ DEM_PATH = Path(__file__).with_name("dem_khumbu.npy")
22
+ DEM_METRES_PER_PX = 4.2
23
+
24
+
25
+ def load_dem() -> np.ndarray:
26
+ return np.load(DEM_PATH).astype(np.float64)
27
+
28
+
29
+ def sample_patch(dem, mpp, rng, patch_m, res, arena, relief,
30
+ detail_m=0.06, detail_width_m=0.15):
31
+ """One res x res crop of `patch_m` metres of the DEM at a random position and
32
+ heading, in [0, 1]. A slope-weighted fine layer adds scree (SRTM has no
33
+ rock-scale detail)."""
34
+ from scipy.ndimage import gaussian_filter, map_coordinates
35
+
36
+ n = dem.shape[0]
37
+ px = patch_m / mpp
38
+ margin = px * 0.75
39
+ ci, cj = rng.uniform(margin, n - margin, size=2)
40
+ theta = rng.uniform(0, 2 * np.pi)
41
+
42
+ u = (np.arange(res) - (res - 1) / 2) / (res - 1) * px
43
+ U, V = np.meshgrid(u, u)
44
+ c, s = np.cos(theta), np.sin(theta)
45
+ h = map_coordinates(dem, [ci - s * U + c * V, cj + c * U + s * V], order=3, mode="nearest")
46
+ h = (h - h.min()) / (h.max() - h.min() + 1e-9)
47
+
48
+ if detail_m > 0:
49
+ gy, gx = np.gradient(h)
50
+ slope = np.hypot(gx, gy)
51
+ slope = slope / (np.percentile(slope, 90) + 1e-9)
52
+ fine = gaussian_filter(rng.normal(size=(res, res)),
53
+ sigma=max(0.6, detail_width_m / (arena / res)))
54
+ fine /= np.abs(fine).max() + 1e-9
55
+ h = h + (detail_m / relief) * np.clip(slope, 0.15, 1.0) * fine
56
+ h = (h - h.min()) / (h.max() - h.min() + 1e-9)
57
+ return h
58
+
59
+
60
+ def make_terrains(n_terrains: int, seed: int = 0, res: int = 256, arena: float = 20.0,
61
+ patch_m: float = 600.0, relief: float = 0.3,
62
+ spawn_clear_r: float = 1.0) -> np.ndarray:
63
+ """(n_terrains, res, res) heightfield grids in [0, 1], spawn-centred at 0.5."""
64
+ dem = load_dem()
65
+ rng = np.random.default_rng(seed)
66
+ u = np.linspace(-arena / 2, arena / 2, res)
67
+ X, Y = np.meshgrid(u, u)
68
+ blend = np.clip((np.hypot(X, Y) - spawn_clear_r) / 0.8, 0.0, 1.0)
69
+
70
+ out = np.empty((n_terrains, res, res), dtype=np.float32)
71
+ for k in range(n_terrains):
72
+ h = sample_patch(dem, DEM_METRES_PER_PX, rng, patch_m, res, arena, relief)
73
+ h0 = float(h[res // 2, res // 2])
74
+ h = h * blend + h0 * (1.0 - blend)
75
+ # centre the spawn height at 0.5 without leaving [0, 1]
76
+ h = 0.5 + 0.5 * (h - h0) / max(h0, 1.0 - h0, 1e-6)
77
+ out[k] = np.clip(h, 0.0, 1.0)
78
+ return out
79
+
80
+
81
+ def apply_terrain(env, grids: np.ndarray, relief: float) -> None:
82
+ """Bake grids[0] into a Playground env's model and rebuild its MJX model.
83
+ Later per-env variation goes through `randomizer`."""
84
+ from mujoco import mjx
85
+
86
+ mj = env._mj_model
87
+ hf = mj.hfield("hfield").id
88
+ floor = mj.geom("floor").id
89
+ mj.hfield_size[hf, 2] = relief # vertical range
90
+ mj.geom_pos[floor, 2] = -0.5 * relief # data 0.5 -> z = 0 under the spawn
91
+ mj.hfield_data[:] = grids[0].ravel()
92
+ env._mjx_model = mjx.put_model(mj, impl=env._config.impl)
93
+
94
+
95
+ def randomizer(base_fn, grids: np.ndarray):
96
+ """Wrap Playground's domain randomizer so every env also gets its own crop
97
+ of the Himalaya (env i uses grids[i % len(grids)])."""
98
+ import jax
99
+ import jax.numpy as jnp
100
+
101
+ flat = jnp.asarray(grids.reshape(len(grids), -1))
102
+
103
+ def fn(model, rng):
104
+ model, in_axes = base_fn(model, rng)
105
+ n = rng.shape[0]
106
+ idx = jnp.arange(n) % len(grids)
107
+ model = model.tree_replace({"hfield_data": flat[idx]})
108
+ in_axes = in_axes.tree_replace({"hfield_data": 0})
109
+ return model, in_axes
110
+
111
+ return fn
requirements.txt CHANGED
@@ -4,3 +4,4 @@ mujoco-mjx>=3.2.7
4
  playground==0.2.0
5
  brax>=0.12.1
6
  huggingface_hub>=0.35
 
 
4
  playground==0.2.0
5
  brax>=0.12.1
6
  huggingface_hub>=0.35
7
+ scipy>=1.10