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

#6
by arminfg - opened
Files changed (5) hide show
  1. README.md +12 -3
  2. app.py +44 -12
  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
@@ -3,6 +3,11 @@
3
  Training runs in a background thread so the Gradio server stays responsive; the
4
  UI is a log tail plus start/stop. Brax PPO cannot be interrupted from outside,
5
  so the stop button sets a flag that the progress callback checks and raises on.
 
 
 
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -24,6 +29,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
 
@@ -58,7 +71,8 @@ def init_upload() -> None:
58
  who = api.whoami().get("name")
59
  api.create_repo(HF_REPO, repo_type="model", exist_ok=True)
60
  UPLOAD["enabled"] = True
61
- log(f"checkpoints -> https://huggingface.co/{HF_REPO} (as {who})")
 
62
  except Exception as e:
63
  log(f"cannot write to HF_REPO={HF_REPO}: {type(e).__name__}: "
64
  f"{str(e).splitlines()[0][:200]}")
@@ -72,8 +86,9 @@ def _upload(path: Path) -> None:
72
  return
73
  try:
74
  from huggingface_hub import HfApi
 
75
  HfApi(token=HF_TOKEN).upload_file(
76
- path_or_fileobj=str(path), path_in_repo=path.name,
77
  repo_id=HF_REPO, repo_type="model")
78
  log(f"uploaded {path.name}")
79
  UPLOAD["fails"] = 0
@@ -153,6 +168,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 +225,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,
@@ -225,8 +254,11 @@ def start(timesteps=None):
225
  t = STATE["thread"]
226
  if t is not None and t.is_alive():
227
  return status_md()
228
- if timesteps:
229
- STATE["target"] = int(timesteps)
 
 
 
230
  STATE["step"] = 0
231
  STATE["stop"] = False
232
  STATE["thread"] = threading.Thread(target=train_worker, daemon=True)
@@ -244,7 +276,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,13 +289,13 @@ 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",
264
- minimum=100_000, maximum=2_000_000_000)
265
- gr.Button("Start", variant="primary").click(start, inputs=steps_in, outputs=st)
266
- gr.Button("Stop", variant="stop").click(stop, outputs=st)
267
  out = gr.Textbox(label="training log", lines=26, max_lines=26,
268
  autoscroll=True, value=logs())
269
  timer = gr.Timer(3.0)
 
3
  Training runs in a background thread so the Gradio server stays responsive; the
4
  UI is a log tail plus start/stop. Brax PPO cannot be interrupted from outside,
5
  so the stop button sets a flag that the progress callback checks and raises on.
6
+
7
+ Start/stop are deliberately NOT exposed as named API endpoints (api_name=False).
8
+ On a public Space, auto-named endpoints get probed -- a caller sent the string
9
+ "ping" to /start and then hit /stop, which killed a run mid-flight. The read-only
10
+ log and status endpoints stay exposed so the run can be monitored remotely.
11
  """
12
 
13
  from __future__ import annotations
 
29
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() or None
30
  AUTO_START = os.environ.get("AUTO_START", "1") == "1"
31
 
32
+ # Terrain: "himalaya" swaps Playground's 5 cm noise heightfield for crops of real
33
+ # SRTM elevation of the Khumbu valley (see himalaya_terrain.py); "playground"
34
+ # keeps the stock terrain. Every env trains on its own crop.
35
+ TERRAIN = os.environ.get("TERRAIN", "himalaya")
36
+ NUM_TERRAINS = int(os.environ.get("NUM_TERRAINS", 64))
37
+ HIMALAYA_RELIEF = float(os.environ.get("HIMALAYA_RELIEF", 0.3)) # metres over the 20 m arena
38
+ HIMALAYA_PATCH = float(os.environ.get("HIMALAYA_PATCH", 600.0)) # metres of real ground per arena
39
+
40
  # /data exists only when persistent storage is attached; fall back to /tmp.
41
  OUT = Path("/data/ckpt") if Path("/data").is_dir() else Path("/tmp/ckpt")
42
 
 
71
  who = api.whoami().get("name")
72
  api.create_repo(HF_REPO, repo_type="model", exist_ok=True)
73
  UPLOAD["enabled"] = True
74
+ sub = f"/tree/main/{TERRAIN}" if TERRAIN != "playground" else ""
75
+ log(f"checkpoints -> https://huggingface.co/{HF_REPO}{sub} (as {who})")
76
  except Exception as e:
77
  log(f"cannot write to HF_REPO={HF_REPO}: {type(e).__name__}: "
78
  f"{str(e).splitlines()[0][:200]}")
 
86
  return
87
  try:
88
  from huggingface_hub import HfApi
89
+ prefix = f"{TERRAIN}/" if TERRAIN != "playground" else ""
90
  HfApi(token=HF_TOKEN).upload_file(
91
+ path_or_fileobj=str(path), path_in_repo=prefix + path.name,
92
  repo_id=HF_REPO, repo_type="model")
93
  log(f"uploaded {path.name}")
94
  UPLOAD["fails"] = 0
 
168
  log(f"loading {ENV_NAME} ...")
169
  env = registry.load(ENV_NAME)
170
  env_cfg = registry.get_default_config(ENV_NAME)
171
+ eval_env = registry.load(ENV_NAME, config=env_cfg)
172
+ randomization_fn = registry.get_domain_randomizer(ENV_NAME)
173
+ if TERRAIN == "himalaya":
174
+ from himalaya_terrain import apply_terrain, make_terrains, randomizer
175
+ log(f"building {NUM_TERRAINS} Himalaya crops: {HIMALAYA_PATCH:.0f} m of Khumbu "
176
+ f"-> 20 m arena, relief {HIMALAYA_RELIEF} m ...")
177
+ grids = make_terrains(NUM_TERRAINS, seed=SEED, patch_m=HIMALAYA_PATCH,
178
+ relief=HIMALAYA_RELIEF)
179
+ apply_terrain(env, grids, HIMALAYA_RELIEF)
180
+ apply_terrain(eval_env, grids, HIMALAYA_RELIEF)
181
+ randomization_fn = randomizer(randomization_fn, grids)
182
+ log("terrain: himalaya (per-env crops via domain randomization)")
183
+ else:
184
+ log("terrain: playground stock rough terrain")
185
  ppo_params = locomotion_params.brax_ppo_config(ENV_NAME)
186
  ppo_params.num_timesteps = int(STATE["target"])
187
  log(f"num_envs={ppo_params.get('num_envs')} "
 
225
  _, params, _ = ppo.train(
226
  **ppo_kwargs,
227
  environment=env,
228
+ eval_env=eval_env,
229
  wrap_env_fn=wrapper.wrap_for_brax_training,
230
+ randomization_fn=randomization_fn,
231
  progress_fn=progress,
232
  policy_params_fn=policy_params_fn,
233
  seed=SEED,
 
254
  t = STATE["thread"]
255
  if t is not None and t.is_alive():
256
  return status_md()
257
+ try:
258
+ if timesteps is not None:
259
+ STATE["target"] = max(100_000, int(float(timesteps)))
260
+ except (TypeError, ValueError):
261
+ log(f"ignoring non-numeric timesteps input: {timesteps!r}")
262
  STATE["step"] = 0
263
  STATE["stop"] = False
264
  STATE["thread"] = threading.Thread(target=train_worker, daemon=True)
 
276
  alive = STATE["thread"] is not None and STATE["thread"].is_alive()
277
  target = int(STATE["target"])
278
  pct = 100.0 * STATE["step"] / max(target, 1)
279
+ return (f"**{ENV_NAME}** · terrain `{TERRAIN}` — status: `{STATE['status']}`"
280
  f"{' (running)' if alive else ''} \n"
281
  f"step {STATE['step']:,} / {target:,} ({pct:.1f}%) \n"
282
  f"checkpoints: `{OUT}`"
 
289
 
290
 
291
  with gr.Blocks(title="G1 rough-terrain training") as demo:
292
+ gr.Markdown("# Unitree G1 — Himalayan terrain locomotion training")
293
  st = gr.Markdown(status_md())
294
  with gr.Row():
295
+ steps_in = gr.Number(value=NUM_TIMESTEPS, precision=0, label="timesteps")
296
+ gr.Button("Start", variant="primary").click(
297
+ start, inputs=steps_in, outputs=st, api_name=False)
298
+ gr.Button("Stop", variant="stop").click(stop, outputs=st, api_name=False)
299
  out = gr.Textbox(label="training log", lines=26, max_lines=26,
300
  autoscroll=True, value=logs())
301
  timer = gr.Timer(3.0)
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