Skip to content

Commit e602138

Browse files
committed
Add RingPattern and HemispherePattern to ray-cast sensor
Two new bundled pattern dataclasses extend the informal Pattern protocol already exposed by GridPattern: RingPattern emits num_horizontal × num_vertical rays for planar or multi-line LIDAR sweeps with automatic wrap-around handling on a full 360° azimuth span, and HemispherePattern distributes num_rays_target rays on a spherical cap via a Fibonacci lattice with configurable pole axis and half-angle (90° hemisphere, 180° full sphere). RayCastSensorCfg.pattern is now typed GridPattern | RingPattern | HemispherePattern; no behaviour change for existing GridPattern users. Bilingual sensors concept page gains a side-by-side pattern reference; seven new unit tests cover azimuth wrap-around, multi-line ordering, hemisphere coverage, antipodal-pole rotation, and end-to-end RayCastSensor integration against the flat-plane backend.
1 parent 9a5c0ca commit e602138

7 files changed

Lines changed: 345 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ trajectory so breaking changes can land in any minor release until the 1.0 stabi
88

99
### Added
1010

11+
- `RingPattern` and `HemispherePattern` ray-cast patterns alongside the existing
12+
`GridPattern`. `RingPattern` covers multi-line LIDAR sweeps (`num_horizontal × num_vertical`
13+
rays, automatic wrap-around handling on a 360° span); `HemispherePattern` distributes rays
14+
on a spherical cap via a Fibonacci lattice with configurable pole axis and half-angle.
15+
`RayCastSensorCfg.pattern` is now typed `GridPattern | RingPattern | HemispherePattern`.
1116
- `genelab.terrains` namespace with `SubTerrainCfg` (abstract) and five concrete
1217
sub-terrains: `FlatPatchCfg`, `PyramidStairsCfg`, `RandomRoughCfg`, `SlopeCfg`,
1318
`WaveCfg`. `TerrainGeneratorCfg` composes them into a 2D grid (random by proportion

‎docs/concepts/sensors.en.md‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,44 @@ the completed durations into `last_air_time` / `last_contact_time` at the contac
9090
buffers. The matching obs terms — `mdp.foot_air_time`, `mdp.foot_contact`,
9191
`mdp.foot_contact_forces` — read straight off this dataclass.
9292

93+
### Ray-cast patterns
94+
95+
`RayCastSensorCfg.pattern` accepts any of the three bundled pattern dataclasses. Custom
96+
patterns satisfy the same informal protocol — `num_rays() -> int` and
97+
`generate(device) -> (starts, dirs)` with both tensors shaped `(M, 3)` in the sensor's local
98+
frame — and slot in without changing `RayCastSensor` itself.
99+
100+
`GridPattern` is the default; rays are parallel and arranged on a 2D rectangle. `RingPattern`
101+
emits `num_horizontal × num_vertical` rays from the origin, evenly spaced in azimuth and
102+
elevation — the typical multi-line LIDAR layout. `HemispherePattern` distributes
103+
`num_rays_target` rays on a spherical cap of half-angle `polar_fov_deg` around `pole_axis`
104+
using a Fibonacci lattice; 90° covers a full hemisphere, 180° a full sphere.
105+
106+
| Pattern | Key fields | Use case |
107+
|---------|------------|----------|
108+
| `GridPattern` | `resolution`, `size`, `direction` | Height-scan grids, area sweeps |
109+
| `RingPattern` | `num_horizontal`, `num_vertical`, `horizontal_fov_deg`, `vertical_fov_deg` | Planar / multi-line LIDAR |
110+
| `HemispherePattern` | `num_rays_target`, `pole_axis`, `polar_fov_deg` | Proximity dome, downward coverage |
111+
112+
`RingPattern` treats a horizontal span of exactly ±360° as a wrap-around and drops the
113+
duplicate closing azimuth; any other span (e.g. `(-30, 30)` for a forward-facing scanner) is
114+
inclusive on both endpoints. `HemispherePattern.num_rays()` returns `num_rays_target`
115+
exactly — the Fibonacci lattice produces no rounding error.
116+
93117
### TerrainHeightSensor
94118

95119
2D grid of downward rays anchored to a robot link. Output is per-ray height above the terrain
96120
(positive = above), useful as a privileged `height_scan` critic observation. The default
97-
backend intersects every ray against a horizontal plane at `ground_height`; subclassing
98-
`RayCastSensor` and overriding `_intersect_world_rays` is the extension point for non-flat
99-
terrain.
121+
backend intersects every ray against a horizontal plane at `ground_height`; when the scene
122+
attaches a `TerrainImporter`, the inner `RayCastSensor` bilinearly samples the height-field
123+
instead. Subclassing `RayCastSensor` and overriding `_intersect_world_rays` is the extension
124+
point for BVH or other custom backends.
100125

101126
| Field | Type | Meaning |
102127
|-------|------|---------|
103128
| `link_name` | `str` | Anchor link for the grid origin. |
104-
| `pattern` | `GridPattern` | Grid resolution / size / direction. |
105-
| `attach_yaw_only` | `bool` | Rotate the grid by yaw only so it stays horizon-aligned. |
129+
| `pattern` | `GridPattern \| RingPattern \| HemispherePattern` | Pattern geometry. |
130+
| `attach_yaw_only` | `bool` | Rotate the pattern by yaw only so it stays horizon-aligned. |
106131
| `max_distance` | `float` | Distance clamp for the ray cast. |
107132
| `ground_height` | `float` | Plane height used by the default flat-plane backend. |
108133

‎docs/concepts/sensors.zh.md‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,40 @@ velocimeter 计算公式为 `v_site = v_link + ω × (R_link · offset)`,再
8787
配套的观测 term —— `mdp.foot_air_time`、`mdp.foot_contact`、`mdp.foot_contact_forces` ——
8888
直接读取这个数据类。
8989

90+
### Ray-cast patterns
91+
92+
`RayCastSensorCfg.pattern` 接受三种内置 pattern 数据类。自定义 pattern 满足同一非正式协议
93+
即可接入 —— `num_rays() -> int` 和 `generate(device) -> (starts, dirs)`,两个张量都为
94+
`(M, 3)`,定义在传感器局部系下 —— 无须改动 `RayCastSensor` 本身。
95+
96+
`GridPattern` 是默认:所有光线平行,原点排在 2D 矩形上。`RingPattern` 从原点发出
97+
`num_horizontal × num_vertical` 条光线,方位角和俯仰角各自均匀分布 —— 典型的多线 LIDAR
98+
布局。`HemispherePattern` 用 Fibonacci 格点把 `num_rays_target` 条光线均匀分布到以
99+
`pole_axis` 为极轴、半角为 `polar_fov_deg` 的球冠上;90° 覆盖整个半球,180° 即整个球面。
100+
101+
| Pattern | 关键字段 | 典型用途 |
102+
|---------|----------|----------|
103+
| `GridPattern` | `resolution`、`size`、`direction` | 高度场网格、面扫 |
104+
| `RingPattern` | `num_horizontal`、`num_vertical`、`horizontal_fov_deg`、`vertical_fov_deg` | 平面 / 多线 LIDAR |
105+
| `HemispherePattern` | `num_rays_target`、`pole_axis`、`polar_fov_deg` | 接近告警球罩、向下覆盖 |
106+
107+
`RingPattern` 将水平角恰好 ±360° 视为环绕扫描,自动丢弃首末重复方位;任意其它跨度(例如
108+
`(-30, 30)` 的前向扫描器)则把两端点都包含进去。`HemispherePattern.num_rays()` 严格返回
109+
`num_rays_target`——Fibonacci 格点不会产生取整误差。
110+
90111
### TerrainHeightSensor
91112

92113
锚定在某个 link 上的 2D 下射光线网格,输出每条光线相对地形的高度(正值表示在地形上方),
93114
适合作为 critic 的 privileged `height_scan` 观测。默认 backend 把每条光线打到位于
94-
`ground_height` 的水平面上;非平地场景的扩展点是继承 `RayCastSensor` 并重载
95-
`_intersect_world_rays`。
115+
`ground_height` 的水平面上;当场景挂上 `TerrainImporter` 时,内部 `RayCastSensor` 会
116+
对 height-field 做双线性采样。非平地场景的扩展点是继承 `RayCastSensor` 并重载
117+
`_intersect_world_rays`,可换成 BVH 或其他自定义 backend。
96118

97119
| 字段 | 类型 | 含义 |
98120
|------|------|------|
99121
| `link_name` | `str` | 网格原点锚定的 link。 |
100-
| `pattern` | `GridPattern` | 网格分辨率 / 尺寸 / 方向。 |
101-
| `attach_yaw_only` | `bool` | 仅按 yaw 旋转网格,使其保持水平。 |
122+
| `pattern` | `GridPattern \| RingPattern \| HemispherePattern` | Pattern 几何。 |
123+
| `attach_yaw_only` | `bool` | 仅按 yaw 旋转 pattern,使其保持水平。 |
102124
| `max_distance` | `float` | 光线距离上限。 |
103125
| `ground_height` | `float` | 默认平面 backend 使用的地面高度。 |
104126

‎src/genelab/lab.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@
4444
ContactSensor,
4545
ContactSensorCfg,
4646
GridPattern,
47+
HemispherePattern,
4748
RayCastData,
4849
RayCastSensor,
4950
RayCastSensorCfg,
51+
RingPattern,
5052
Sensor,
5153
SensorCfg,
5254
TerrainHeightSensor,
@@ -98,6 +100,7 @@ class GenesisBackendCfg:
98100
"GenesisBackendCfg",
99101
"Gnoise",
100102
"GridPattern",
103+
"HemispherePattern",
101104
"IdealPDActuator",
102105
"IdealPDActuatorCfg",
103106
"ImplicitPDActuator",
@@ -116,6 +119,7 @@ class GenesisBackendCfg:
116119
"RegistryEntry",
117120
"RigidObject",
118121
"RigidObjectCfg",
122+
"RingPattern",
119123
"RobotState",
120124
"Sensor",
121125
"SensorCfg",

‎src/genelab/sensor/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from genelab.sensor.contact import ContactData, ContactSensor, ContactSensorCfg
55
from genelab.sensor.ray_cast import (
66
GridPattern,
7+
HemispherePattern,
78
RayCastData,
89
RayCastSensor,
910
RayCastSensorCfg,
11+
RingPattern,
1012
)
1113
from genelab.sensor.sensor import Sensor, SensorCfg
1214
from genelab.sensor.terrain_height import TerrainHeightSensor, TerrainHeightSensorCfg
@@ -18,9 +20,11 @@
1820
"ContactSensor",
1921
"ContactSensorCfg",
2022
"GridPattern",
23+
"HemispherePattern",
2124
"RayCastData",
2225
"RayCastSensor",
2326
"RayCastSensorCfg",
27+
"RingPattern",
2428
"Sensor",
2529
"SensorCfg",
2630
"TerrainHeightSensor",

‎src/genelab/sensor/ray_cast.py‎

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
1-
"""Ray-cast sensor with a configurable grid pattern, attached to a robot link.
1+
"""Ray-cast sensor with a configurable pattern, attached to a robot link.
22
33
Two intersection backends:
44
55
* **Flat plane** (default when ``scene.terrain is None``): every ray hits the horizontal
6-
plane at ``ground_height``. Closed-form intersection.
6+
plane at ``ground_height``. Closed-form intersection; rays going up or parallel are
7+
treated as misses and clamp to ``max_distance``.
78
* **Height field** (when ``scene.terrain`` is a :class:`TerrainImporter`): bilinearly
89
samples ``terrain.heightfield_tensor`` at each ray's ``(x, y)`` origin and uses the
910
sampled elevation as the hit plane height. Accurate for vertical / near-vertical rays
1011
(the dominant case for height-scan grids); subclasses can override
1112
``_intersect_world_rays`` for non-trivial BVH ray-tracing.
1213
13-
Pattern shape mirrors mjlab's ``GridPatternCfg``; for now only a 2D grid is provided.
14+
Three built-in patterns share an informal protocol (``num_rays() -> int`` and
15+
``generate(device) -> (starts, dirs)``):
16+
17+
* :class:`GridPattern` — 2D rectangular grid, all rays parallel. Default; mirrors mjlab.
18+
* :class:`RingPattern` — multi-line LIDAR-style sweep around the +z axis.
19+
* :class:`HemispherePattern` — Fibonacci-distributed rays on a spherical cap.
1420
"""
1521

22+
import math
1623
from dataclasses import dataclass, field
1724
from typing import TYPE_CHECKING
1825

@@ -56,6 +63,142 @@ def generate(self, device: str) -> tuple[torch.Tensor, torch.Tensor]:
5663
return starts, dirs
5764

5865

66+
def _evenly_spaced_angles_rad(
67+
start_deg: float, end_deg: float, n: int, device: str
68+
) -> torch.Tensor:
69+
"""Return ``n`` azimuths in radians spanning ``[start_deg, end_deg]``.
70+
71+
A full ±360° span is treated as a wrap-around and the closing endpoint is dropped
72+
(``linspace(..., n+1)[:-1]``) so a 360° sweep with ``n`` rays does not duplicate the
73+
first/last angle. Any other span uses inclusive linspace.
74+
"""
75+
span = end_deg - start_deg
76+
if n <= 0:
77+
return torch.empty(0, device=device, dtype=torch.float32)
78+
if abs(abs(span) - 360.0) < 1e-6:
79+
angles_deg = torch.linspace(start_deg, end_deg, n + 1, device=device)[:-1]
80+
else:
81+
angles_deg = torch.linspace(start_deg, end_deg, n, device=device)
82+
return angles_deg * (math.pi / 180.0)
83+
84+
85+
@dataclass
86+
class RingPattern:
87+
"""Multi-line LIDAR sweep: ``num_horizontal`` azimuths × ``num_vertical`` elevations.
88+
89+
All rays emit from the sensor origin (``starts = zeros``). ``horizontal_fov_deg`` and
90+
``vertical_fov_deg`` are inclusive on both endpoints except when the horizontal span is
91+
exactly ±360° — then the closing azimuth is dropped to avoid a duplicate ray. A single
92+
horizontal line (``num_vertical=1``) at ``vertical_fov_deg=(0, 0)`` gives a planar lidar.
93+
"""
94+
95+
num_horizontal: int = 32
96+
num_vertical: int = 1
97+
horizontal_fov_deg: tuple[float, float] = (-180.0, 180.0)
98+
vertical_fov_deg: tuple[float, float] = (-15.0, 15.0)
99+
100+
def num_rays(self) -> int:
101+
return self.num_horizontal * self.num_vertical
102+
103+
def generate(self, device: str) -> tuple[torch.Tensor, torch.Tensor]:
104+
n_h = self.num_horizontal
105+
n_v = self.num_vertical
106+
azimuth = _evenly_spaced_angles_rad(
107+
self.horizontal_fov_deg[0], self.horizontal_fov_deg[1], n_h, device
108+
)
109+
if n_v == 1:
110+
elevation = torch.tensor(
111+
[(self.vertical_fov_deg[0] + self.vertical_fov_deg[1]) * 0.5 * math.pi / 180.0],
112+
device=device,
113+
dtype=torch.float32,
114+
)
115+
else:
116+
elevation = torch.linspace(
117+
self.vertical_fov_deg[0],
118+
self.vertical_fov_deg[1],
119+
n_v,
120+
device=device,
121+
) * (math.pi / 180.0)
122+
# Outer product over (vertical, horizontal); reshape so vertical varies slowest.
123+
el_grid, az_grid = torch.meshgrid(elevation, azimuth, indexing="ij")
124+
cos_el = torch.cos(el_grid).reshape(-1)
125+
sin_el = torch.sin(el_grid).reshape(-1)
126+
cos_az = torch.cos(az_grid).reshape(-1)
127+
sin_az = torch.sin(az_grid).reshape(-1)
128+
dirs = torch.stack([cos_el * cos_az, cos_el * sin_az, sin_el], dim=-1).contiguous()
129+
starts = torch.zeros_like(dirs)
130+
return starts, dirs
131+
132+
133+
def _fibonacci_cap_dirs_z_aligned(n: int, polar_fov_deg: float, device: str) -> torch.Tensor:
134+
"""Return ``(n, 3)`` unit vectors evenly distributed on a spherical cap around +z.
135+
136+
Uses a Fibonacci lattice with area-uniform polar spacing on the cap. ``polar_fov_deg``
137+
is the half-angle from the pole; 90° covers a full hemisphere, 180° a full sphere.
138+
"""
139+
fov_rad = polar_fov_deg * math.pi / 180.0
140+
cos_theta_min = math.cos(fov_rad)
141+
golden_angle = math.pi * (3.0 - math.sqrt(5.0))
142+
143+
i = torch.arange(n, device=device, dtype=torch.float32)
144+
cos_theta = 1.0 - (i + 0.5) / float(n) * (1.0 - cos_theta_min)
145+
sin_theta = (1.0 - cos_theta * cos_theta).clamp(min=0.0).sqrt()
146+
phi = i * golden_angle
147+
x = sin_theta * torch.cos(phi)
148+
y = sin_theta * torch.sin(phi)
149+
z = cos_theta
150+
return torch.stack([x, y, z], dim=-1)
151+
152+
153+
def _rotate_z_to(z_aligned: torch.Tensor, target_axis: tuple[float, float, float]) -> torch.Tensor:
154+
"""Rotate ``(N, 3)`` vectors expressed in a frame where +z is the pole onto a frame
155+
where the pole has been mapped to ``target_axis`` (unit vector after normalisation)."""
156+
device = z_aligned.device
157+
dtype = z_aligned.dtype
158+
target = torch.tensor(target_axis, device=device, dtype=dtype)
159+
target = target / target.norm().clamp_min(1e-9)
160+
cos_a = float(target[2].item())
161+
if cos_a > 1.0 - 1e-9:
162+
return z_aligned
163+
if cos_a < -1.0 + 1e-9:
164+
# 180° rotation about +x: (x, y, z) → (x, -y, -z).
165+
flipped = z_aligned.clone()
166+
flipped[..., 1] = -flipped[..., 1]
167+
flipped[..., 2] = -flipped[..., 2]
168+
return flipped
169+
# Rodrigues' rotation: axis = z × target, angle = arccos(target · z).
170+
z_unit = torch.tensor([0.0, 0.0, 1.0], device=device, dtype=dtype)
171+
axis = torch.linalg.cross(z_unit, target)
172+
axis = axis / axis.norm().clamp_min(1e-9)
173+
sin_a = math.sqrt(max(0.0, 1.0 - cos_a * cos_a))
174+
cross = torch.linalg.cross(axis.expand_as(z_aligned), z_aligned)
175+
dot = (axis * z_aligned).sum(dim=-1, keepdim=True)
176+
return z_aligned * cos_a + cross * sin_a + axis * dot * (1.0 - cos_a)
177+
178+
179+
@dataclass
180+
class HemispherePattern:
181+
"""Fibonacci-distributed rays on a spherical cap centred on ``pole_axis``.
182+
183+
All rays emit from the sensor origin (``starts = zeros``). ``polar_fov_deg`` is the
184+
half-angle from the pole: 90° is a full hemisphere, 180° a full sphere. ``num_rays``
185+
is the exact ray count produced by ``generate()`` (no Fibonacci rounding involved).
186+
"""
187+
188+
num_rays_target: int = 64
189+
pole_axis: tuple[float, float, float] = (0.0, 0.0, 1.0)
190+
polar_fov_deg: float = 90.0
191+
192+
def num_rays(self) -> int:
193+
return self.num_rays_target
194+
195+
def generate(self, device: str) -> tuple[torch.Tensor, torch.Tensor]:
196+
dirs_z = _fibonacci_cap_dirs_z_aligned(self.num_rays_target, self.polar_fov_deg, device)
197+
dirs = _rotate_z_to(dirs_z, self.pole_axis).contiguous()
198+
starts = torch.zeros_like(dirs)
199+
return starts, dirs
200+
201+
59202
@dataclass
60203
class RayCastData:
61204
distances: torch.Tensor # (B, M) — clamped to max_distance; equals max_distance on a miss
@@ -69,14 +212,19 @@ class RayCastData:
69212
class RayCastSensorCfg(SensorCfg):
70213
"""Configuration for ``RayCastSensor``.
71214
72-
``link_name`` anchors the pattern. ``attach_yaw_only`` keeps the grid axis-aligned to the
73-
horizon when the link rolls / pitches (typical for terrain height scans on a torso link);
74-
set ``False`` to follow the full link orientation. ``ground_height`` is the z of the flat
75-
plane the default backend intersects against; subclasses ignore it.
215+
``link_name`` anchors the pattern. ``attach_yaw_only`` keeps the pattern axis-aligned to
216+
the horizon when the link rolls / pitches (typical for terrain height scans on a torso
217+
link); set ``False`` to follow the full link orientation. ``ground_height`` is the z of
218+
the flat plane the default backend intersects against; subclasses ignore it.
219+
220+
``pattern`` accepts any of the bundled pattern dataclasses (:class:`GridPattern`,
221+
:class:`RingPattern`, :class:`HemispherePattern`); custom patterns satisfying the same
222+
informal protocol (``num_rays() -> int`` and ``generate(device) -> (starts, dirs)``)
223+
are accepted but should be re-exported through ``genelab.sensor`` for typing parity.
76224
"""
77225

78226
link_name: str = ""
79-
pattern: GridPattern = field(default_factory=GridPattern)
227+
pattern: GridPattern | RingPattern | HemispherePattern = field(default_factory=GridPattern)
80228
attach_yaw_only: bool = True
81229
max_distance: float = 10.0
82230
ground_height: float = 0.0

0 commit comments

Comments
 (0)