Skip to content

Commit 4116e81

Browse files
committed
Add FrameTransformerSensor for FK-style relative pose
FrameTransformerSensor is a stateless forward-kinematics probe that outputs both the world-frame and source-frame pose of one or more target frames relative to a single source frame. Source and each target support a rigid offset (position + quaternion) in the link's local frame, so the same link can host multiple attachment sites without modifying the MJCF. The output's N axis follows TargetFrameCfg order so observation terms can slice by position; the matching target_names attribute on the sensor maps cfg index to human-facing name. bind rejects unknown link names and empty target_frames, mirroring the contact-sensor unresolved-link error path. No update / reset overrides needed — robot_state.link_pos / link_quat_w give all the state the composition reads. Six new unit tests cover identity source pose, target-side offset composition, source-side offset shift, multi-target ordering, unknown-link rejection (multiple names listed), and empty-target-list rejection.
1 parent 9a5c0ca commit 4116e81

7 files changed

Lines changed: 349 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
- `FrameTransformerSensor` (with `FrameTransformerSensorCfg`, `TargetFrameCfg`, and
12+
`FrameTransformerData`): stateless forward-kinematics probe that outputs the world-frame
13+
pose AND source-frame pose for one or more target frames relative to a single source
14+
frame. Both source and target support rigid local-frame offsets (position + quaternion).
15+
`target_frames` is order-preserving so observation terms can slice by position; the
16+
matching `target_names: tuple[str, ...]` attribute exposes column identities. `bind`
17+
rejects unknown link names and empty `target_frames` (mirrors the contact-sensor
18+
unresolved-link error path).
1119
- `genelab.terrains` namespace with `SubTerrainCfg` (abstract) and five concrete
1220
sub-terrains: `FlatPatchCfg`, `PyramidStairsCfg`, `RandomRoughCfg`, `SlopeCfg`,
1321
`WaveCfg`. `TerrainGeneratorCfg` composes them into a 2D grid (random by proportion

‎docs/concepts/sensors.en.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ 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+
### FrameTransformerSensor
94+
95+
Stateless forward-kinematics probe: outputs the pose of one or more target frames relative
96+
to a single source frame, with rigid offsets in each link's local frame. Both the world-frame
97+
pose and the source-frame pose are exposed in the data class so reward / observation terms
98+
can pick either basis without re-running the math themselves. Typical uses include reading
99+
an end-effector's pose in the base link's frame, comparing two foot-tip frames during gait
100+
shaping, or expressing a payload's pose relative to a grasp site.
101+
102+
| Field | Type | Meaning |
103+
|-------|------|---------|
104+
| `source_link_name` | `str` | Reference frame the targets are expressed in. |
105+
| `source_offset_pos` | `tuple[float, float, float]` | Source-side offset in the link's local frame. |
106+
| `source_offset_quat` | `tuple[float, float, float, float]` | Source-side rotation offset (wxyz). |
107+
| `target_frames` | `tuple[TargetFrameCfg, ...]` | Order-preserving target list — the output's `N` axis follows cfg order. |
108+
109+
Each `TargetFrameCfg` carries `link_name`, optional `name` (defaults to `link_name`),
110+
`offset_pos`, and `offset_quat`. Sensor attribute `target_names: tuple[str, ...]` is exposed
111+
so consumers can resolve a column by name. `bind` rejects unknown link names and empty
112+
`target_frames`.
113+
93114
### TerrainHeightSensor
94115

95116
2D grid of downward rays anchored to a robot link. Output is per-ray height above the terrain

‎docs/concepts/sensors.zh.md‎

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

90+
### FrameTransformerSensor
91+
92+
无状态的正向运动学探针:输出一个或多个目标系相对于单一源系的位姿,源 / 目标在各自
93+
link 局部系下都支持刚性偏移。数据类同时暴露世界系位姿与源系位姿,下游 reward /
94+
observation term 可按需取用而不必自行复合运算。典型场景:在 base link 系下读取末端执行器
95+
位姿、对比两个脚尖系做步态约束、把负载位姿表达在抓取点局部系下。
96+
97+
| 字段 | 类型 | 含义 |
98+
|------|------|------|
99+
| `source_link_name` | `str` | 表达 target 所用的参考系。 |
100+
| `source_offset_pos` | `tuple[float, float, float]` | 源端在 link 局部系下的位移偏移。 |
101+
| `source_offset_quat` | `tuple[float, float, float, float]` | 源端旋转偏移(wxyz)。 |
102+
| `target_frames` | `tuple[TargetFrameCfg, ...]` | 保序的 target 列表 —— 输出 `N` 轴顺序即 cfg 顺序。 |
103+
104+
每个 `TargetFrameCfg` 含 `link_name`、可选的 `name`(默认为 `link_name`)、`offset_pos`
105+
与 `offset_quat`。传感器属性 `target_names: tuple[str, ...]` 暴露给下游按名字定位列。
106+
`bind` 会拒绝未知 link 名以及空 `target_frames`。
107+
90108
### TerrainHeightSensor
91109

92110
锚定在某个 link 上的 2D 下射光线网格,输出每条光线相对地形的高度(正值表示在地形上方),

‎src/genelab/lab.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,16 @@
4343
ContactData,
4444
ContactSensor,
4545
ContactSensorCfg,
46+
FrameTransformerData,
47+
FrameTransformerSensor,
48+
FrameTransformerSensorCfg,
4649
GridPattern,
4750
RayCastData,
4851
RayCastSensor,
4952
RayCastSensorCfg,
5053
Sensor,
5154
SensorCfg,
55+
TargetFrameCfg,
5256
TerrainHeightSensor,
5357
TerrainHeightSensorCfg,
5458
)
@@ -95,6 +99,9 @@ class GenesisBackendCfg:
9599
"DCMotorActuator",
96100
"DCMotorActuatorCfg",
97101
"FlatPatchCfg",
102+
"FrameTransformerData",
103+
"FrameTransformerSensor",
104+
"FrameTransformerSensorCfg",
98105
"GenesisBackendCfg",
99106
"Gnoise",
100107
"GridPattern",
@@ -121,6 +128,7 @@ class GenesisBackendCfg:
121128
"SensorCfg",
122129
"SimulationCfg",
123130
"SubTerrainCfg",
131+
"TargetFrameCfg",
124132
"TaskCfg",
125133
"TerrainGenerator",
126134
"TerrainGeneratorCfg",

‎src/genelab/sensor/__init__.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
from genelab.sensor.body_velocity import BodyVelocitySensor, BodyVelocitySensorCfg
44
from genelab.sensor.contact import ContactData, ContactSensor, ContactSensorCfg
5+
from genelab.sensor.frame_transformer import (
6+
FrameTransformerData,
7+
FrameTransformerSensor,
8+
FrameTransformerSensorCfg,
9+
TargetFrameCfg,
10+
)
511
from genelab.sensor.ray_cast import (
612
GridPattern,
713
RayCastData,
@@ -17,12 +23,16 @@
1723
"ContactData",
1824
"ContactSensor",
1925
"ContactSensorCfg",
26+
"FrameTransformerData",
27+
"FrameTransformerSensor",
28+
"FrameTransformerSensorCfg",
2029
"GridPattern",
2130
"RayCastData",
2231
"RayCastSensor",
2332
"RayCastSensorCfg",
2433
"Sensor",
2534
"SensorCfg",
35+
"TargetFrameCfg",
2636
"TerrainHeightSensor",
2737
"TerrainHeightSensorCfg",
2838
]
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Frame transformer sensor.
2+
3+
Outputs the pose of one or more target frames relative to a source frame, using forward
4+
kinematics over the link state already maintained by ``Articulation``. Both source and
5+
target frames support a configurable local-frame offset (position + quaternion) so a sensor
6+
can probe a specific point on a link (e.g. an end-effector grasp pose) without requiring the
7+
MJCF to expose a dedicated body.
8+
9+
Stateless: ``_compute_data`` reads ``rs.link_pos`` / ``rs.link_quat_w`` directly each tick
10+
and composes per-target offsets cached at ``bind`` time. No buffers, no integrators, no
11+
finite-difference — ``update`` and ``reset`` only need the base-class cache invalidation.
12+
"""
13+
14+
from dataclasses import dataclass, field
15+
from typing import TYPE_CHECKING
16+
17+
import torch
18+
19+
from genelab.sensor.sensor import Sensor, SensorCfg
20+
from genelab.utils.math import quat_apply, quat_mul, subtract_frame_transforms
21+
22+
if TYPE_CHECKING:
23+
from genelab.envs.manager_based_rl_env import ManagerBasedRlEnv
24+
25+
26+
@dataclass
27+
class TargetFrameCfg:
28+
"""A single target frame in a :class:`FrameTransformerSensorCfg`.
29+
30+
``link_name`` resolves to a link index at ``bind``. ``offset_pos`` / ``offset_quat`` apply
31+
a rigid offset in the link's local frame so the same link can host multiple targets at
32+
different attachment sites. ``name`` is the user-facing identifier — defaults to
33+
``link_name`` when blank — and is exposed via ``FrameTransformerSensor.target_names``.
34+
"""
35+
36+
link_name: str = ""
37+
name: str = ""
38+
offset_pos: tuple[float, float, float] = (0.0, 0.0, 0.0)
39+
offset_quat: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0)
40+
41+
42+
@dataclass
43+
class FrameTransformerSensorCfg(SensorCfg):
44+
"""Configuration for ``FrameTransformerSensor``.
45+
46+
``source_link_name`` is the reference frame the targets are expressed in. ``target_frames``
47+
is order-preserving — the output's ``N`` axis follows the cfg order so downstream
48+
observation terms can slice by position.
49+
"""
50+
51+
source_link_name: str = ""
52+
source_offset_pos: tuple[float, float, float] = (0.0, 0.0, 0.0)
53+
source_offset_quat: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0)
54+
target_frames: tuple[TargetFrameCfg, ...] = field(default_factory=tuple)
55+
56+
def build(self) -> "FrameTransformerSensor":
57+
return FrameTransformerSensor(self)
58+
59+
60+
@dataclass
61+
class FrameTransformerData:
62+
target_pos_w: torch.Tensor # (B, N, 3) target positions in world frame
63+
target_quat_w: torch.Tensor # (B, N, 4) target orientations in world frame
64+
target_pos_source: torch.Tensor # (B, N, 3) target positions in source frame
65+
target_quat_source: torch.Tensor # (B, N, 4) target orientations in source frame
66+
67+
68+
class FrameTransformerSensor(Sensor[FrameTransformerData]):
69+
def __init__(self, cfg: FrameTransformerSensorCfg) -> None:
70+
super().__init__(cfg)
71+
self._cfg_typed = cfg
72+
self._source_idx: int = -1
73+
self._source_offset_pos: torch.Tensor | None = None
74+
self._source_offset_quat: torch.Tensor | None = None
75+
self._target_indices: torch.Tensor | None = None
76+
self._target_offsets_pos: torch.Tensor | None = None
77+
self._target_offsets_quat: torch.Tensor | None = None
78+
self._target_names: tuple[str, ...] = ()
79+
80+
@property
81+
def target_names(self) -> tuple[str, ...]:
82+
return self._target_names
83+
84+
def bind(self, env: "ManagerBasedRlEnv") -> None:
85+
super().bind(env)
86+
if not self._cfg_typed.source_link_name:
87+
raise ValueError(
88+
f"FrameTransformerSensorCfg(name={self._cfg.name!r}) requires source_link_name"
89+
)
90+
if not self._cfg_typed.target_frames:
91+
raise ValueError(
92+
f"FrameTransformerSensorCfg(name={self._cfg.name!r}) requires at least one target frame"
93+
)
94+
try:
95+
self._source_idx = env.link_names.index(self._cfg_typed.source_link_name)
96+
except ValueError as exc:
97+
raise ValueError(
98+
f"sensor {self._cfg.name!r}: source link {self._cfg_typed.source_link_name!r} "
99+
f"not in env.link_names={env.link_names!r}"
100+
) from exc
101+
unresolved: list[str] = [
102+
t.link_name for t in self._cfg_typed.target_frames if t.link_name not in env.link_names
103+
]
104+
if unresolved:
105+
raise ValueError(
106+
f"sensor {self._cfg.name!r}: target link(s) {unresolved!r} not in "
107+
f"env.link_names={env.link_names!r}"
108+
)
109+
device = env.device
110+
self._source_offset_pos = torch.tensor(
111+
self._cfg_typed.source_offset_pos, dtype=torch.float32, device=device
112+
)
113+
self._source_offset_quat = torch.tensor(
114+
self._cfg_typed.source_offset_quat, dtype=torch.float32, device=device
115+
)
116+
self._target_indices = torch.tensor(
117+
[env.link_names.index(t.link_name) for t in self._cfg_typed.target_frames],
118+
dtype=torch.long,
119+
device=device,
120+
)
121+
self._target_offsets_pos = torch.tensor(
122+
[list(t.offset_pos) for t in self._cfg_typed.target_frames],
123+
dtype=torch.float32,
124+
device=device,
125+
)
126+
self._target_offsets_quat = torch.tensor(
127+
[list(t.offset_quat) for t in self._cfg_typed.target_frames],
128+
dtype=torch.float32,
129+
device=device,
130+
)
131+
self._target_names = tuple(
132+
t.name if t.name else t.link_name for t in self._cfg_typed.target_frames
133+
)
134+
135+
def _compute_data(self) -> FrameTransformerData:
136+
assert (
137+
self._env is not None
138+
and self._source_offset_pos is not None
139+
and self._source_offset_quat is not None
140+
and self._target_indices is not None
141+
and self._target_offsets_pos is not None
142+
and self._target_offsets_quat is not None
143+
)
144+
rs = self._env.robot_state
145+
b = self._env.num_envs
146+
n = self._target_indices.shape[0]
147+
148+
# Source pose in world frame, then composed with the source-local offset.
149+
src_link_pos = rs.link_pos[:, self._source_idx] # (B, 3)
150+
src_link_quat = rs.link_quat_w[:, self._source_idx] # (B, 4)
151+
src_offset_pos_b = self._source_offset_pos.unsqueeze(0).expand(b, 3).contiguous()
152+
src_offset_quat_b = self._source_offset_quat.unsqueeze(0).expand(b, 4).contiguous()
153+
source_pos_w = src_link_pos + quat_apply(src_link_quat, src_offset_pos_b)
154+
source_quat_w = quat_mul(src_link_quat, src_offset_quat_b)
155+
156+
# Per-target link pose, then composed with the target-local offset.
157+
tgt_link_pos = rs.link_pos[:, self._target_indices] # (B, N, 3)
158+
tgt_link_quat = rs.link_quat_w[:, self._target_indices] # (B, N, 4)
159+
tgt_offset_pos_b = self._target_offsets_pos.unsqueeze(0).expand(b, n, 3).contiguous()
160+
tgt_offset_quat_b = self._target_offsets_quat.unsqueeze(0).expand(b, n, 4).contiguous()
161+
target_pos_w = tgt_link_pos + quat_apply(tgt_link_quat, tgt_offset_pos_b)
162+
target_quat_w = quat_mul(tgt_link_quat, tgt_offset_quat_b)
163+
164+
# Express each target in the source frame: t_source = inv(source) @ t_target.
165+
source_pos_expanded = source_pos_w.unsqueeze(1).expand(b, n, 3).contiguous()
166+
source_quat_expanded = source_quat_w.unsqueeze(1).expand(b, n, 4).contiguous()
167+
target_pos_source, target_quat_source = subtract_frame_transforms(
168+
source_pos_expanded, source_quat_expanded, target_pos_w, target_quat_w
169+
)
170+
return FrameTransformerData(
171+
target_pos_w=target_pos_w,
172+
target_quat_w=target_quat_w,
173+
target_pos_source=target_pos_source,
174+
target_quat_source=target_quat_source,
175+
)

0 commit comments

Comments
 (0)