Skip to content

Commit 212f990

Browse files
authored
Merge pull request #16 from KraHsu/feat/m3-terrain-curriculum
Add terrain_levels_vel curriculum + terrains concept page
2 parents 2e26292 + cb5637f commit 212f990

9 files changed

Lines changed: 537 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ trajectory so breaking changes can land in any minor release until the 1.0 stabi
2323
- `TerrainImporter` gains `heightfield_tensor(device, dtype)` (device-keyed cache),
2424
`horizontal_scale`, `vertical_scale`, and `terrain_origin` accessors used by the
2525
ray-cast sampler.
26+
- `TerrainImporter` tracks per-env curriculum state via `terrain_levels` /
27+
`terrain_cols` / `spawn_pos`; `init_per_env_state(num_envs, device)` is called once
28+
by `InteractiveScene.build()` post-Genesis-build. `update_env_origins(env_ids)`
29+
recomputes spawn origins after a level change.
30+
- `genelab.mdp.curriculums.terrain_levels_vel`: curriculum term that promotes /
31+
demotes each env's terrain level by how far it walked from spawn (XY distance vs
32+
configurable `distance_threshold` and `demote_ratio`), then writes the new spawn
33+
pose into the sim via `Articulation.write_root_state`.
34+
- `docs/concepts/terrains.{en,zh}.md`: bilingual concept page covering the generator,
35+
importer, sensor integration, curriculum, and known failure modes.
2636
- `genelab.actuator` namespace with `ActuatorBase` and three concrete electromechanical
2737
models: `ImplicitPDActuator` (Genesis-internal PD), `IdealPDActuator` (Python-side PD
2838
via `control_dofs_force`), `DCMotorActuator` (`IdealPD` plus driving-direction torque

‎docs/concepts/terrains.en.md‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Terrains
2+
3+
`genelab.terrains` is a thin GeneLab-shaped wrapper over Genesis's native
4+
`gs.morphs.Terrain` morph. Sub-terrain configs map onto Genesis's built-in subterrain
5+
string types, `TerrainGenerator` assembles them into a 2D grid, and `TerrainImporter`
6+
spawns the terrain into the scene plus tracks per-env curriculum state.
7+
8+
## Why a dedicated layer
9+
10+
Genesis ships the height-field terrain primitives, but its native API uses pydantic
11+
options keyed by string names (`"pyramid_stairs_terrain"`, `"random_uniform_terrain"`).
12+
A GeneLab cfg layer keeps the surface dataclass-shaped (matching the rest of the project),
13+
gives every sub-terrain a typed Python class with explicit fields, and tracks per-env
14+
spawn / level state so curriculum terms can promote and demote without touching the
15+
Genesis handle directly.
16+
17+
## Three shipped sub-terrains
18+
19+
| Class | Genesis type | Parameters |
20+
|---|---|---|
21+
| `FlatPatchCfg` | `flat_terrain` | none |
22+
| `PyramidStairsCfg` | `pyramid_stairs_terrain` | `step_width`, `step_height` |
23+
| `RandomRoughCfg` | `random_uniform_terrain` | `min_height`, `max_height`, `step`, `downsampled_scale` |
24+
25+
`PyramidStairsCfg(step_height=-0.1)` builds concentric descending steps; positive
26+
`step_height` would invert the pyramid. `RandomRoughCfg` samples bumps at
27+
`downsampled_scale` then upsamples, so the apparent feature size is independent of
28+
`horizontal_scale`. Three more Genesis types (`sloped`, `wave`, `stepping_stones`) are
29+
not wrapped yet — extending `SubTerrainCfg` to expose them is a 20-line task.
30+
31+
## Composing a terrain grid
32+
33+
```python
34+
from genelab.terrains import (
35+
FlatPatchCfg,
36+
PyramidStairsCfg,
37+
RandomRoughCfg,
38+
TerrainGeneratorCfg,
39+
)
40+
from genelab.configs import InteractiveSceneCfg
41+
42+
scene = InteractiveSceneCfg(
43+
env_spacing=(0.0, 0.0),
44+
terrain=TerrainGeneratorCfg(
45+
num_rows=4,
46+
num_cols=4,
47+
subterrain_size=(8.0, 8.0),
48+
horizontal_scale=0.1,
49+
vertical_scale=0.005,
50+
sub_terrains={
51+
"flat": FlatPatchCfg(proportion=1.0),
52+
"stairs": PyramidStairsCfg(step_width=0.5, step_height=-0.08, proportion=2.0),
53+
"rough": RandomRoughCfg(min_height=-0.05, max_height=0.05, proportion=1.0),
54+
},
55+
curriculum=True,
56+
seed=0,
57+
),
58+
)
59+
```
60+
61+
`sub_terrains` is a `dict[str, SubTerrainCfg]`; the key is the local cfg label, the
62+
value carries the Genesis parameters. `layout` defaults to `None` (random tiling by
63+
`proportion`); pass a `tuple[tuple[str, ...], ...]` of cfg keys to pin the grid
64+
deterministically.
65+
66+
!!! warning "One parameter set per Genesis type"
67+
Genesis keys `subterrain_parameters` by the *type string*, not per cell. Two
68+
`PyramidStairsCfg` instances with different `step_width` collapse to a single
69+
parameter set. Use distinct keys for distinct geometries, or rely on
70+
`randomize=True` on the importer for in-type variation.
71+
72+
## Ray-cast sensor integration
73+
74+
When `InteractiveSceneCfg.terrain` is set, `InteractiveScene.build()` spawns
75+
`gs.morphs.Terrain` instead of the default plane and exposes the importer as
76+
`scene.terrain`. `RayCastSensor` checks for it and, when present, bilinearly samples
77+
`terrain.heightfield_tensor` at each ray's world `(x, y)` to compute hit elevation.
78+
`TerrainHeightSensor` is a thin wrapper around the same path, so the per-link height
79+
scan output reflects the true terrain instead of a constant plane.
80+
81+
Vertical and near-vertical rays match the underlying height field to within
82+
`vertical_scale`. Oblique rays use the elevation at the ray-start `(x, y)` — accurate
83+
for height-scan grids but approximate for BVH-style queries; override
84+
`RayCastSensor._intersect_world_rays` for those.
85+
86+
## Curriculum: `terrain_levels_vel`
87+
88+
```python
89+
from genelab.managers import CurriculumTermCfg
90+
from genelab.mdp import terrain_levels_vel
91+
92+
curriculum_cfg = {
93+
"terrain_levels": CurriculumTermCfg(
94+
func=terrain_levels_vel,
95+
params={"distance_threshold": 2.0, "demote_ratio": 0.5},
96+
),
97+
}
98+
```
99+
100+
Each reset, the term compares every env's walked distance (XY norm of `root_pos -
101+
spawn_pos`) against `distance_threshold`. Envs that walked further than the threshold
102+
move up one row of the difficulty grid (capped at `num_rows - 1`); envs that walked
103+
less than `distance_threshold * demote_ratio` move down (floored at 0). The curriculum
104+
then writes the new spawn pose into the sim via `Articulation.write_root_state`, so
105+
the next episode starts on the new sub-terrain. The manager logs the mean level under
106+
`Curriculum/terrain_levels`.
107+
108+
## Failure modes worth knowing
109+
110+
* **Empty `sub_terrains`** — `TerrainGenerator.__init__` raises immediately; the
111+
generator has nothing to tile.
112+
* **`layout` shape mismatch** — `ValueError` lists the actual vs declared shape.
113+
* **`layout` references unknown key** — `ValueError` lists the missing keys.
114+
* **Per-env state access before build** — `terrain_levels` / `terrain_cols` /
115+
`spawn_pos` raise `RuntimeError` until `init_per_env_state` runs;
116+
`InteractiveScene.build` calls it automatically.
117+
* **`heightfield` access before spawn** — `RuntimeError`; spawn first, then read.
118+
119+
## See also
120+
121+
- [Configs](configs.md)
122+
- [Sensors](sensors.md)
123+
- [Actuators](actuators.md)

‎docs/concepts/terrains.zh.md‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# 地形
2+
3+
`genelab.terrains` 是 Genesis 原生 `gs.morphs.Terrain` morph 之上的一层 GeneLab 风格薄包装。子地形 cfg 对应 Genesis 内建的 subterrain 字符串类型,`TerrainGenerator` 把它们拼成一个二维网格,`TerrainImporter` 负责把地形 spawn 进场景并跟踪每个 env 的课程状态。
4+
5+
## 为什么需要专门的一层
6+
7+
Genesis 自带 height-field 地形原语,但其原生 API 使用按字符串名键控的 pydantic options(`"pyramid_stairs_terrain"`、`"random_uniform_terrain"`)。GeneLab 的 cfg 层把接口保持成 dataclass 形态(与项目其它部分一致),让每种子地形拥有带显式字段的 typed Python 类,并跟踪每个 env 的 spawn / level 状态,让课程项可以提升和降级而不直接接触 Genesis 句柄。
8+
9+
## 自带 3 种子地形
10+
11+
| 类 | Genesis 类型 | 参数 |
12+
|---|---|---|
13+
| `FlatPatchCfg` | `flat_terrain` | 无 |
14+
| `PyramidStairsCfg` | `pyramid_stairs_terrain` | `step_width`、`step_height` |
15+
| `RandomRoughCfg` | `random_uniform_terrain` | `min_height`、`max_height`、`step`、`downsampled_scale` |
16+
17+
`PyramidStairsCfg(step_height=-0.1)` 生成一圈圈向下的同心方形台阶;正的 `step_height` 会反转金字塔方向。`RandomRoughCfg` 在 `downsampled_scale` 下采样后再上采样,所以表观特征尺寸与 `horizontal_scale` 无关。Genesis 还有另外 3 种类型(`sloped`、`wave`、`stepping_stones`)尚未包装 —— 扩展 `SubTerrainCfg` 暴露它们是一个 20 行的小活。
18+
19+
## 拼装地形网格
20+
21+
```python
22+
from genelab.terrains import (
23+
FlatPatchCfg,
24+
PyramidStairsCfg,
25+
RandomRoughCfg,
26+
TerrainGeneratorCfg,
27+
)
28+
from genelab.configs import InteractiveSceneCfg
29+
30+
scene = InteractiveSceneCfg(
31+
env_spacing=(0.0, 0.0),
32+
terrain=TerrainGeneratorCfg(
33+
num_rows=4,
34+
num_cols=4,
35+
subterrain_size=(8.0, 8.0),
36+
horizontal_scale=0.1,
37+
vertical_scale=0.005,
38+
sub_terrains={
39+
"flat": FlatPatchCfg(proportion=1.0),
40+
"stairs": PyramidStairsCfg(step_width=0.5, step_height=-0.08, proportion=2.0),
41+
"rough": RandomRoughCfg(min_height=-0.05, max_height=0.05, proportion=1.0),
42+
},
43+
curriculum=True,
44+
seed=0,
45+
),
46+
)
47+
```
48+
49+
`sub_terrains` 是 `dict[str, SubTerrainCfg]`;key 是本地 cfg 标签,value 携带 Genesis 参数。`layout` 默认为 `None`(按 `proportion` 随机平铺);传入 `tuple[tuple[str, ...], ...]` 形态的 cfg key 二维表则可以确定性地固定网格。
50+
51+
!!! warning "每个 Genesis 类型只有一套参数"
52+
Genesis 的 `subterrain_parameters` 按*类型字符串*键控,而不是按单元格。两个 `step_width` 不同的 `PyramidStairsCfg` 实例会塌成单一一套参数。要区分几何就用不同的 key,要做同类型内变化就在 importer 上启用 `randomize=True`。
53+
54+
## ray-cast 传感器接入
55+
56+
当 `InteractiveSceneCfg.terrain` 被设置时,`InteractiveScene.build()` 会 spawn `gs.morphs.Terrain` 而不是默认 plane,并把 importer 暴露为 `scene.terrain`。`RayCastSensor` 检测到地形后,会对每条射线在世界坐标 `(x, y)` 处对 `terrain.heightfield_tensor` 做双线性采样,得到命中点高度。`TerrainHeightSensor` 是同一路径上的薄封装,所以每个关节的 height scan 输出反映的是真实地形而不是常数平面。
57+
58+
垂直 / 接近垂直的射线,输出与底层 height field 的误差不超过 `vertical_scale`。斜射线使用射线起点 `(x, y)` 处的高度 —— 对 height-scan 网格足够准,但对 BVH 风格查询是近似的;这类场景请覆写 `RayCastSensor._intersect_world_rays`。
59+
60+
## 课程项: `terrain_levels_vel`
61+
62+
```python
63+
from genelab.managers import CurriculumTermCfg
64+
from genelab.mdp import terrain_levels_vel
65+
66+
curriculum_cfg = {
67+
"terrain_levels": CurriculumTermCfg(
68+
func=terrain_levels_vel,
69+
params={"distance_threshold": 2.0, "demote_ratio": 0.5},
70+
),
71+
}
72+
```
73+
74+
每次 reset,该项把每个 env 的已走距离(`root_pos - spawn_pos` 在 XY 上的范数)与 `distance_threshold` 比较。走得超过阈值的 env 在难度网格中上移一行(封顶在 `num_rows - 1`);走得不到 `distance_threshold * demote_ratio` 的 env 下移一行(地板在 0)。课程项随后通过 `Articulation.write_root_state` 把新 spawn 位姿写入仿真,下一个 episode 就从新子地形开始。manager 自动以 `Curriculum/terrain_levels` 记录平均 level。
75+
76+
## 需要知道的失败模式
77+
78+
* **`sub_terrains` 为空** —— `TerrainGenerator.__init__` 立刻抛错;生成器没东西可拼。
79+
* **`layout` 形状不匹配** —— `ValueError` 列出实际形状与声明形状。
80+
* **`layout` 引用未知 key** —— `ValueError` 列出缺失的 key。
81+
* **build 前访问 per-env 状态** —— `terrain_levels` / `terrain_cols` / `spawn_pos` 在 `init_per_env_state` 之前抛 `RuntimeError`;`InteractiveScene.build` 会自动调一次。
82+
* **spawn 前访问 `heightfield`** —— `RuntimeError`;先 spawn 再读。
83+
84+
## See also
85+
86+
- [Configs](configs.md)
87+
- [Sensors](sensors.md)
88+
- [Actuators](actuators.md)

‎mkdocs.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ plugins:
9898
Registry: 注册表
9999
Configs: 配置系统
100100
Sensors: 传感器
101+
Terrains: 地形
101102
Extensions: 扩展加载
102103
Examples: 示例
103104
Inverted Pendulum: 倒立摆
@@ -162,6 +163,7 @@ nav:
162163
- Configs: concepts/configs.md
163164
- Actuators: concepts/actuators.md
164165
- Sensors: concepts/sensors.md
166+
- Terrains: concepts/terrains.md
165167
- Extensions: concepts/extensions.md
166168
- Examples:
167169
- Overview: examples/overview.md

‎src/genelab/mdp/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
UniformVelocityCommand,
99
UniformVelocityCommandCfg,
1010
)
11+
from genelab.mdp.curriculums import terrain_levels_vel
1112
from genelab.mdp.events import (
1213
push_by_setting_velocity,
1314
reset_joints_to_default,
@@ -104,6 +105,7 @@
104105
"robot_body_pos_b",
105106
"root_height_below",
106107
"sensor_data",
108+
"terrain_levels_vel",
107109
"time_out",
108110
"track_angular_velocity_z_exp",
109111
"track_linear_velocity_xy_exp",

‎src/genelab/mdp/curriculums.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Reusable curriculum term functions: adapt difficulty / spawn distribution per episode."""
2+
3+
from typing import TYPE_CHECKING
4+
5+
import torch
6+
7+
if TYPE_CHECKING:
8+
from genelab.envs.manager_based_rl_env import ManagerBasedRlEnv
9+
10+
11+
def terrain_levels_vel(
12+
env: "ManagerBasedRlEnv",
13+
env_ids: torch.Tensor | slice | None,
14+
distance_threshold: float,
15+
demote_ratio: float = 0.5,
16+
) -> torch.Tensor:
17+
"""Promote / demote each env's terrain level by how far it walked from spawn.
18+
19+
Computes the horizontal (XY) distance between the env's current root position and
20+
its last recorded spawn position. Envs that walked more than ``distance_threshold``
21+
move up one level (capped at ``cfg.num_rows - 1``); envs that walked less than
22+
``distance_threshold * demote_ratio`` move down (floored at 0). Levels then drive a
23+
fresh spawn origin lookup on the importer, and the curriculum writes the new root
24+
pose into the sim so the next episode starts on the new sub-terrain.
25+
26+
No-op when ``env.scene.terrain`` is ``None``.
27+
28+
Returns the mean terrain level across all envs, so the curriculum manager logs it
29+
under ``"Curriculum/<term-name>"`` automatically.
30+
"""
31+
terrain = env.scene.terrain
32+
if terrain is None:
33+
return torch.zeros((), device=env.device)
34+
# Curriculum manager passes ``None`` on the first frame and a 1D tensor on resets;
35+
# ``slice`` is accepted for parity with the manager's type signature.
36+
if env_ids is None or isinstance(env_ids, slice):
37+
return terrain.terrain_levels.float().mean()
38+
if env_ids.numel() == 0:
39+
return terrain.terrain_levels.float().mean()
40+
41+
current_pos = env.articulation.data.root_pos[env_ids]
42+
spawn_pos = terrain.spawn_pos[env_ids]
43+
walked = torch.linalg.vector_norm(current_pos[:, :2] - spawn_pos[:, :2], dim=-1)
44+
45+
move_up = walked > distance_threshold
46+
move_down = walked < distance_threshold * demote_ratio
47+
delta = move_up.long() - move_down.long()
48+
levels = terrain.terrain_levels
49+
new_levels = (levels[env_ids] + delta).clamp(0, terrain.cfg.num_rows - 1)
50+
levels[env_ids] = new_levels
51+
52+
new_origins = terrain.update_env_origins(env_ids)
53+
n = int(env_ids.numel())
54+
zero_quat = torch.zeros(n, 4, device=env.device)
55+
zero_quat[:, 0] = 1.0
56+
zero_vel = torch.zeros(n, 3, device=env.device)
57+
env.articulation.write_root_state(new_origins, zero_quat, zero_vel, zero_vel, env_ids)
58+
59+
return levels.float().mean()

‎src/genelab/scene/interactive_scene.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ def build(self) -> None:
134134
for entity in self._entities.values():
135135
if isinstance(entity, Articulation):
136136
entity.bind(self._num_envs, self._device)
137+
if self._terrain is not None:
138+
self._terrain.init_per_env_state(self._num_envs, self._device)
137139
self._built = True
138140

139141
def _compute_env_origins(self) -> torch.Tensor:

0 commit comments

Comments
 (0)