Skip to content

Commit ff4b3d9

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 97abf7a commit ff4b3d9

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
- `IMUSensor` (with `IMUSensorCfg` and `IMUData`) sits alongside `BodyVelocitySensor` and
1220
outputs orientation, body-frame projected unit gravity, and body-frame linear / angular
1321
acceleration at a site rigidly attached to a link. Accelerations are computed by finite

‎docs/concepts/sensors.en.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ the completed durations into `last_air_time` / `last_contact_time` at the contac
112112
buffers. The matching obs terms — `mdp.foot_air_time`, `mdp.foot_contact`,
113113
`mdp.foot_contact_forces` — read straight off this dataclass.
114114

115+
### FrameTransformerSensor
116+
117+
Stateless forward-kinematics probe: outputs the pose of one or more target frames relative
118+
to a single source frame, with rigid offsets in each link's local frame. Both the world-frame
119+
pose and the source-frame pose are exposed in the data class so reward / observation terms
120+
can pick either basis without re-running the math themselves. Typical uses include reading
121+
an end-effector's pose in the base link's frame, comparing two foot-tip frames during gait
122+
shaping, or expressing a payload's pose relative to a grasp site.
123+
124+
| Field | Type | Meaning |
125+
|-------|------|---------|
126+
| `source_link_name` | `str` | Reference frame the targets are expressed in. |
127+
| `source_offset_pos` | `tuple[float, float, float]` | Source-side offset in the link's local frame. |
128+
| `source_offset_quat` | `tuple[float, float, float, float]` | Source-side rotation offset (wxyz). |
129+
| `target_frames` | `tuple[TargetFrameCfg, ...]` | Order-preserving target list — the output's `N` axis follows cfg order. |
130+
131+
Each `TargetFrameCfg` carries `link_name`, optional `name` (defaults to `link_name`),
132+
`offset_pos`, and `offset_quat`. Sensor attribute `target_names: tuple[str, ...]` is exposed
133+
so consumers can resolve a column by name. `bind` rejects unknown link names and empty
134+
`target_frames`.
135+
115136
### Ray-cast patterns
116137

117138
`RayCastSensorCfg.pattern` accepts any of the three bundled pattern dataclasses. Custom

‎docs/concepts/sensors.zh.md‎

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

110+
### FrameTransformerSensor
111+
112+
无状态的正向运动学探针:输出一个或多个目标系相对于单一源系的位姿,源 / 目标在各自
113+
link 局部系下都支持刚性偏移。数据类同时暴露世界系位姿与源系位姿,下游 reward /
114+
observation term 可按需取用而不必自行复合运算。典型场景:在 base link 系下读取末端执行器
115+
位姿、对比两个脚尖系做步态约束、把负载位姿表达在抓取点局部系下。
116+
117+
| 字段 | 类型 | 含义 |
118+
|------|------|------|
119+
| `source_link_name` | `str` | 表达 target 所用的参考系。 |
120+
| `source_offset_pos` | `tuple[float, float, float]` | 源端在 link 局部系下的位移偏移。 |
121+
| `source_offset_quat` | `tuple[float, float, float, float]` | 源端旋转偏移(wxyz)。 |
122+
| `target_frames` | `tuple[TargetFrameCfg, ...]` | 保序的 target 列表 —— 输出 `N` 轴顺序即 cfg 顺序。 |
123+
124+
每个 `TargetFrameCfg` 含 `link_name`、可选的 `name`(默认为 `link_name`)、`offset_pos`
125+
与 `offset_quat`。传感器属性 `target_names: tuple[str, ...]` 暴露给下游按名字定位列。
126+
`bind` 会拒绝未知 link 名以及空 `target_frames`。
127+
110128
### Ray-cast patterns
111129

112130
`RayCastSensorCfg.pattern` 接受三种内置 pattern 数据类。自定义 pattern 满足同一非正式协议

‎src/genelab/lab.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
ContactData,
4444
ContactSensor,
4545
ContactSensorCfg,
46+
FrameTransformerData,
47+
FrameTransformerSensor,
48+
FrameTransformerSensorCfg,
4649
GridPattern,
4750
HemispherePattern,
4851
IMUData,
@@ -54,6 +57,7 @@
5457
RingPattern,
5558
Sensor,
5659
SensorCfg,
60+
TargetFrameCfg,
5761
TerrainHeightSensor,
5862
TerrainHeightSensorCfg,
5963
)
@@ -100,6 +104,9 @@ class GenesisBackendCfg:
100104
"DCMotorActuator",
101105
"DCMotorActuatorCfg",
102106
"FlatPatchCfg",
107+
"FrameTransformerData",
108+
"FrameTransformerSensor",
109+
"FrameTransformerSensorCfg",
103110
"GenesisBackendCfg",
104111
"Gnoise",
105112
"GridPattern",
@@ -131,6 +138,7 @@ class GenesisBackendCfg:
131138
"SensorCfg",
132139
"SimulationCfg",
133140
"SubTerrainCfg",
141+
"TargetFrameCfg",
134142
"TaskCfg",
135143
"TerrainGenerator",
136144
"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.imu import IMUData, IMUSensor, IMUSensorCfg
612
from genelab.sensor.ray_cast import (
713
GridPattern,
@@ -20,6 +26,9 @@
2026
"ContactData",
2127
"ContactSensor",
2228
"ContactSensorCfg",
29+
"FrameTransformerData",
30+
"FrameTransformerSensor",
31+
"FrameTransformerSensorCfg",
2332
"GridPattern",
2433
"HemispherePattern",
2534
"IMUData",
@@ -31,6 +40,7 @@
3140
"RingPattern",
3241
"Sensor",
3342
"SensorCfg",
43+
"TargetFrameCfg",
3444
"TerrainHeightSensor",
3545
"TerrainHeightSensorCfg",
3646
]
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)