编写自定义传感器#
本页面向要添加新传感器类型的高级用户。它是 传感器管线 的作者视角对照篇:后者描述传感器在运行时如何执行;这里关注你要实现的接口、每个重写必须遵守的 shape 与 dtype 契约,以及框架如何自动把你的传感器与它的选项类配对。如果你只想使用内置传感器,请从 传感器 开始。
基类位于 genesis/engine/sensors/base_sensor.py。几乎所有情况下都应继承 SimpleSensor,只重写自己需要的 hook。只有完全绕过标准管线的传感器才直接继承 Sensor,内置相机就是通过 BaseCameraSensor 这样做的。
最小可运行示例#
一个完整的传感器由三个类组成:面向用户的选项 dataclass、按类共享的元数据容器,以及传感器本身。下面的接近传感器报告所挂连杆到世界原点的距离,并截断到最大量程。它已经足以通过 scene.add_sensor(...) 使用,并从 SimpleSensor 继承了全部误差特性(噪声、偏置、随机游走、延迟、抖动、历史),这些特性由 SimpleSensor 统一施加。
# my_plugin/options.py
from genesis.options.sensors.options import RigidSensorOptionsMixin, SimpleSensorOptions
class MyProximity(
RigidSensorOptionsMixin["MyProximitySensor"],
SimpleSensorOptions["MyProximitySensor"],
):
max_range: float = 1.0 # meters
# my_plugin/sensor.py
from dataclasses import dataclass
import torch
import genesis as gs
from genesis.engine.sensors.base_sensor import (
RigidSensorMetadataMixin,
RigidSensorMixin,
SimpleSensor,
SimpleSensorMetadata,
)
from genesis.utils.misc import concat_with_tensor, make_tensor_field
from .options import MyProximity
@dataclass
class MyProximityMetadata(RigidSensorMetadataMixin, SimpleSensorMetadata):
# Per-sensor ranges, concatenated across every instance of this class at build time.
max_range: torch.Tensor = make_tensor_field((0,))
class MyProximitySensor(
RigidSensorMixin[MyProximityMetadata],
SimpleSensor[MyProximity, None, MyProximityMetadata],
):
def build(self):
# The manager calls build() once per instance. Append this sensor's parameters
# to the shared, per-class tensors so one kernel can run over every instance.
super().build()
self._shared_metadata.max_range = concat_with_tensor(
self._shared_metadata.max_range,
float(self._options.max_range),
expand=(1,),
)
def _get_return_format(self) -> tuple[int, ...]:
return (1,) # one scalar per sensor
@classmethod
def _get_cache_dtype(cls) -> torch.dtype:
return gs.tc_float
@classmethod
def _update_raw_data(cls, shared_context, shared_metadata, raw_data_T):
pos = shared_metadata.solver.get_links_pos(shared_metadata.links_idx) # (B, N, 3)
dist = pos.norm(dim=-1).clamp(max=shared_metadata.max_range) # (B, N)
raw_data_T.copy_(dist.T) # write in place; buffer is (cols, B), column-major
本页余下部分解释每一块为什么存在,以及更复杂的传感器还会重写哪些额外的 hook。
你需要编写的类#
每个传感器都贡献同样的三样产物,另有两样可选的。
选项类: 一个公共 dataclass,承载每个传感器的全部参数,继承
SimpleSensorOptions(或合适的 mixin)。它以传感器类作为前向引用进行泛型参数化,如SimpleSensorOptions["MyProximitySensor"]。这是用户唯一需要构造的对象。元数据类: 按传感器类共享的运行时状态,由场景中该传感器的每个实例共用。
SimpleSensor派生的传感器继承SimpleSensorMetadata,直接派生自Sensor的传感器则继承SharedSensorMetadata。传感器类: 具体实现。它继承
SimpleSensor[OptionsT, ContextT, MetadataT],并重写下面的 hook。NamedTuple返回类型(可选): 当传感器返回多个张量时声明一个,并作为第四个类型参数传入。见返回 NamedTuple。SharedSensorContext子类(可选): 仅当该传感器与其他传感器类型共享昂贵资源时才声明,并作为第二个类型参数传入。见跨传感器类型共享资源。
泛型签名是 Sensor[OptionsT, ContextT, MetadataT, DataT]:先是选项,然后是跨类型共享上下文(没有则为 None),接着是元数据类型,最后是数据类型(DataT 默认为 tuple)。
注册是自动的#
传感器从不需要手动注册。当 Sensor 子类把它的选项类作为第一个类型参数时,Sensor.__init_subclass__ 会在类体执行的那一刻把这对关系记录到 SensorManager.SENSOR_TYPES_MAP 中。之后用户只需构造选项实例并交给 scene.add_sensor(...),由后者解析出传感器类、实例化并返回传感器。
这样就只剩两种受支持的放置方式:
仓库内(内置传感器): 选项放在
genesis/options/sensors/*.py,传感器放在genesis/engine/sensors/*.py。两者都通过各自包的__init__导入,因此在导入时就完成配对注册。仓库外(第三方插件): 把选项和传感器放在同一个包的两个同级子模块中(例如
my_plugin/options.py和my_plugin/sensor.py)。只要你的代码在构造选项之前导入了选项模块,框架就会在首次调用scene.add_sensor(...)时透明地解析出传感器类。
Note
__init_subclass__ 还会强制检查契约:声明了自己选项类的具体传感器也必须声明其元数据类型参数;任何重写 _post_process 的类都必须声明一个中间缓冲区(见投影到不同的返回类型)。两种违规都会在类定义时抛出 TypeError,早于任何场景构建。
必须重写的 hook#
每个具体传感器都必须重写两个 hook,SimpleSensor 又额外增加一个。
_get_return_format(self) -> tuple[...]: 实例方法,返回read()产物的形状。形状按设计是按实例的,因为选项可以合理地决定它(光线投射器的 pattern、相机的分辨率、接近传感器的探针位置)。单个含N个标量的张量返回(N,);多张量返回则返回元组的元组,例如((3,), (3,), (3,))。_get_cache_dtype(cls) -> torch.dtype: 类方法,返回read()产物的 dtype。dtype 是按类统一的,而不是按实例:管理器把一个类的每个实例打包进按 dtype 缓冲区中该类的连续切片,因此所有实例必须共享同一种 dtype。若需要不同 dtype,请使用两个传感器类。_update_raw_data(cls, shared_context, shared_metadata, raw_data_T):SimpleSensor的 kernel,在当前步为该类的每个传感器计算真值。这是SimpleSensor唯一的抽象产出 hook。
用实例方法定形状、用类方法定 dtype,这种划分是刻意为之。它让管理器无需实例化传感器就能确定每类 dtype,同时仍允许每实例形状依赖选项。
raw_data_T 的形状是 (cols, B)(列主序,批次维 B 在最后),这样当多个传感器类写入同一个中间缓存时,每类的行切片仍是 C 连续的。始终原地填充它:
@classmethod
def _update_raw_data(cls, shared_context, shared_metadata, raw_data_T):
pos = shared_metadata.solver.get_links_pos(shared_metadata.links_idx) # (B, N, 3)
raw_data_T.copy_(pos.reshape(pos.shape[0], -1).T) # (3*N, B)
shared_context 是跨类型资源,或者为 None;大多数传感器会忽略它。hook 每步按类调用一次,绝不按实例、也绝不按环境调用,因此要相应地向量化。
选择基类#
基类 |
使用场景 |
|---|---|
|
几乎总是选它。标准的逐步管线:原始值、物理误差、变换、硬件误差、后处理、延迟采样。 |
|
同样的管线,但 |
|
在 |
|
仅当两种标准管线都不适用时。自行实现 |
Mixin 可以组合到基类上:
RigidSensorOptionsMixin/KinematicSensorOptionsMixin: 位于选项侧,分别用于挂载到RigidEntity或任意KinematicEntity的传感器。通过多重继承与SimpleSensorOptions组合。RigidSensorMixin/RigidSensorMetadataMixin: 位于传感器和元数据侧,提供带类型的solver字段、links_idx记录,以及set_pos_offset/set_quat_offset。
按类共享的元数据#
元数据类保存的是按传感器类(而非按实例)且会被 hook 读取的内容:求解器与实体引用、构建时拼接的逐传感器索引张量(links_idx、thresholds、max_range)、逐传感器偏移、滤波器系数,以及用于门控慢路径的预计算标志。
SimpleSensorMetadata 已经提供了误差状态(noise、bias、random_walk、resolution、jitter_ts)以及对应的 has_any_* 标志。继承它,并用 make_tensor_field((shape,)) 声明字段,让它们自动分配:
@dataclass
class MySharedMetadata(RigidSensorMetadataMixin, SimpleSensorMetadata):
thresholds: torch.Tensor = make_tensor_field((0,))
custom_offsets: torch.Tensor = make_tensor_field((0, 3))
在传感器的 build() 中用 concat_with_tensor 追加本实例的条目,如上面的最小示例所示。管理器在场景构建时对每个传感器实例调用一次 build();正是在这里扩充共享张量,才使得单个 kernel 能一次跑遍所有实例。
可选的管线 hook#
SimpleSensor 运行一条固定的逐步管线,每个阶段都有默认实现。只有传感器需要时才重写某个阶段。各阶段按分支、按以下顺序运行:
真值分支:
_update_raw_data,然后_apply_transform(is_measured=False),然后_post_process(is_measured=False)。测量分支:
_update_raw_data,然后_apply_physics_imperfections,然后_apply_transform(is_measured=True),然后_apply_hardware_imperfections,然后_post_process(is_measured=True),最后延迟采样。
两个分支各自维护自己的中间空间时间线环,保存变换后、硬件误差前的值,因此有状态的 _apply_transform 滤波器读到的总是未受硬件噪声污染的旧槽位。三个噪声阶段的区别在于扰动在物理上源自何处。
_apply_physics_imperfections(cls, shared_metadata, data, timeline): 仿真器未建模的底层现象的随机波动(真实的漂移、场上的细尺度湍流)。仅作用于测量分支,在_apply_transform之前施加,因此它会通过响应模型传播到后续步骤。默认:无操作。_apply_transform(cls, shared_metadata, data, timeline, *, is_measured): 对传感器元件做坐标变换和/或有状态的响应建模(热质量、RC 时间常数、机械带宽)。两个分支都会调用;坐标部分无条件执行,而对不应出现在真值中的元件特有影响,用if is_measured:来门控。原地修改data;用timeline.at(1)、timeline.at(2)读取历史。IMU 用它做本体坐标系对齐旋转;温度网格传感器用它做 RC 滤波。_apply_hardware_imperfections(cls, shared_metadata, measured_slot_0): 读出电子器件在传感器输出端引入的扰动。SimpleSensor已在这里实现noise、bias、random_walk和resolution,并由has_any_*标志门控,因此全零的类会完全跳过。仅在需要非标准模型时重写,并先调用super()处理标准项:
@classmethod
def _apply_hardware_imperfections(cls, shared_metadata, measured_slot_0):
super()._apply_hardware_imperfections(shared_metadata, measured_slot_0)
# Signal-dependent noise floor, resampled each step.
measured_slot_0 += torch.normal(0.0, shared_metadata.signal_noise_coeff) * measured_slot_0.abs()
对于噪声是物理计算固有部分的传感器(单次 kernel pass 必须同时产生理想值和带噪值,因为噪声会影响 kernel 的分支),应改为重写 _update_current_timestep_data。它在一次 pass 中写入真值切片和带噪的测量槽位,管线其余部分仍在其之上运行。
投影到不同的返回类型#
_post_process(cls, shared_metadata, tensor, timeline, *, is_measured) -> torch.Tensor 把管线内部的中间空间投影到面向用户的返回空间。当输出类型或形状与内部表示不同时重写它:例如 ContactSensor 上的 bool 阈值,ContactForceSensor 上的死区与饱和。返回投影后的张量;管理器会把它写入返回空间环并做延迟采样。
@classmethod
def _post_process(cls, shared_metadata, tensor, timeline, *, is_measured):
return tensor > shared_metadata.thresholds # float intermediate, bool return
重写 _post_process 要求同时重写 _get_intermediate_format 和/或 _get_intermediate_dtype,否则框架会在类定义时抛出 TypeError。原因是结构性的:中间缓冲区必须是独立的缓冲区,因为时间线环位于中间空间,混用数据空间会破坏任何读取旧槽位的 _apply_transform 滤波器。当投影确实保持 shape 和 dtype 不变时,可把其中之一重写为返回返回空间值的 no-op:这个重写本身就是对两个缓冲区相互独立的显式确认。
_get_intermediate_format(self): 内部缓冲区的形状;默认为_get_return_format()。_get_intermediate_dtype(cls): 内部缓冲区的 dtype;默认为_get_cache_dtype()。ContactSensor把它重写为gs.tc_float,因为它的 kernel 是 float,而返回是 bool。
返回 NamedTuple#
多张量返回时,声明一个 NamedTuple 并作为第四个类型参数传入。_get_return_format 随后按字段顺序为每个字段返回一个形状:
class IMUReturnType(NamedTuple):
lin_acc: torch.Tensor
ang_vel: torch.Tensor
mag: torch.Tensor
class IMUSensor(SimpleSensor[IMU, None, IMUSharedMetadata, IMUReturnType]):
def _get_return_format(self) -> tuple[tuple[int, ...], ...]:
return ((3,), (3,), (3,)) # shapes match the NamedTuple field order
@classmethod
def _get_cache_dtype(cls) -> torch.dtype:
return gs.tc_float # one dtype across all fields (class-uniform)
管理器为每个传感器分配一整块连续内存,读取时再切片;read() 重建并返回该 NamedTuple,当场景没有环境时去掉前导批次维。每个字段的形状为 ([n_envs,] *field_shape)。
相机类传感器#
BaseCameraSensor 是直接继承 Sensor 的类,它把每个内置相机的“读取时惰性渲染”模式固定下来(RasterizerCameraSensor、RaytracerCameraSensor、BatchRendererCameraSensor)。凡是靠渲染场景、而不是靠每步读取物理信号来产生图像的传感器,都可以用它。它为你提供:
读取时惰性渲染 + 逐步缓存: 同一控制步内的多次
read()共享一次渲染,因此你永远不需要实现_update_shared_cache。连杆挂载,配合
pos/lookat/up,把世界空间变换交给你的渲染器逐帧应用。RGB 输出,形状为
([n_envs,] h, w, 3),dtype 为torch.uint8,由options.res声明,以CameraReturnTypeNamedTuple返回。
它不参与环形管线(uses_ring_pipeline = False),并在构造时拒绝 delay、jitter 和 history_length,因为这些都依赖它不分配的返回空间环。实现两个 hook:
class MyCameraSensor(BaseCameraSensor[MyCameraOptions]):
def _apply_camera_transform(self, camera_T: torch.Tensor) -> None:
# camera_T is a (4, 4) world-space transform. Apply it to your renderer's camera.
...
def _render_current_state(self) -> None:
# Render into this camera's slot of the per-class image cache. At most once per step.
...
完整的示例参见 RasterizerCameraSensor。这里无法使用标准的误差开关;任何误差模型都必须放在 _render_current_state 或重写的 _post_process 中。对于非 RGB 输出(深度、分割、法线),请重写 _get_return_format / _get_cache_dtype 并调整底层存储,或者退回到直接继承 Sensor。
内置传感器重写了什么#
要挑对 hook,就参照最接近的内置传感器。每个都实现了 _get_return_format 和 _get_cache_dtype;下表列出额外的重写。
传感器 |
|
|
|
|---|---|---|---|
|
是 |
— |
恒等;返回 |
|
是 |
— |
bool 阈值;返回 |
|
是 |
— |
clamp 与死区;shape 和 dtype 保持不变,用 no-op 的中间重写作为确认 |
|
是 |
是(本体坐标系对齐) |
恒等;返回 |
|
是 |
— |
恒等 |
|
是 |
是(读取 |
恒等 |
任意 |
— |
— |
恒等;继承 |
每个 SimpleSensor 都原样继承 _apply_hardware_imperfections;只有需要非标准误差模型时才重写它。
绘制调试标记#
以 draw_debug=True 创建的传感器会在每次刷新时,在查看器中、以及渲染调试几何的相机中绘制标记。重写 _draw_debug(self, context, envs_idx) 来定义它们。该方法是返回标记,而不是添加标记:用 context.make_debug_* 方法(line、arrow、sphere、spheres、box、mesh、frame、frames、points、pyramid,参数与对应的 scene.draw_debug_* 相同)构建每个标记,并为每个标记返回一个 (env_idx, node, None) 三元组。查看器负责添加它们,并在下次刷新时替换。
envs_idx 列出要绘制的已渲染环境,是 context.rendered_envs_idx 中的索引。env_idx 是标记所属的已渲染环境;对于跨多个环境的标记(例如用第一个环境的数据绘制、在所有环境中显示),它为 None:
def _draw_debug(self, context, envs_idx):
# Draw the reading of the first rendered environment, if it is not stale
if 0 not in envs_idx:
return []
env_idx = context.rendered_envs_idx[0] if self._manager._sim.n_envs > 0 else None
if not self.read(env_idx):
return []
pos = self._link.get_pos(env_idx, relative=False).reshape((3,))
return [(None, context.make_debug_sphere(pos=pos, radius=0.02, color=(1.0, 0.0, 0.0, 1.0)), None)]
传感器的数据由一步计算产生,因此当状态在没有执行步进的情况下发生变化(例如通过 entity.set_pos(...) 或 entity.set_qpos(...))后,数据就不再与场景匹配。类属性 invalidated_by 列出刚体与运动学求解器中会使数据失效、直到下一步才恢复的变更种类(gs.engine.solvers.base_solver.StateChange)。它默认包含所有变更种类,这永远是安全的。数据只依赖部分状态的传感器可以缩小这个范围,例如只依赖连杆位置的传感器可用 frozenset((StateChange.GEOMETRY,))。调试标记展示的就是数据,因此在此类变更触及的环境中,标记会一直隐藏到下一步,而不是在新位姿上显示旧数据。
需要复核的要点#
原地填充
raw_data_T,绝不重新绑定它。 写成raw_data_T = something_new会让框架拥有的缓冲区原封不动,并悄悄破坏管线。请通过raw_data_T.copy_(...)、raw_data_T[...] = ...,或把它作为输出参数的 kernel 来写入。读取是幂等的。 不要在
read()内修改状态。状态变更应放在管理器每步调用一次的逐步 hook 中。hook 按类运行一次,而不是按实例或按环境。 在拼接后的每类张量上向量化。
shape 按实例,dtype 按类。
_get_return_format和_get_intermediate_format是实例方法,因此选项可以影响形状;_get_cache_dtype和_get_intermediate_dtype是类方法,因为每个实例共享同一种 dtype。复用代码库中的工具。
genesis.utils.misc中的concat_with_tensor、make_tensor_field和tensor_to_array符合每个内置传感器遵循的约定。