🧰 Implementing Custom Sensors#
This page is a guide for advanced users who want to add their own sensor type. It is the writer’s counterpart to Sensor Pipeline, which describes how the pipeline executes at runtime; here we focus on which hooks to override, what shape/dtype contracts they must satisfy, and how the automatic plugin registration works.
In almost every case, derive from SimpleSensor and override only the hooks you need. Deriving directly from Sensor is reserved for sensors that bypass the standard pipeline entirely (the built-in cameras do this).
What you write to add a sensor#
To add a new sensor you contribute four artifacts:
Artifact |
Where |
Role |
|---|---|---|
|
|
Public, user-facing dataclass that carries every per-sensor parameter. Inherits |
|
next to the sensor implementation |
Per-sensor-class runtime state shared across every instance of this sensor in the scene. Inherits |
|
next to the sensor implementation |
The sensor class itself. Inherits |
(optional) a |
next to the sensor implementation |
If your sensor returns multiple tensors (e.g. IMU returns |
Genesis pairs an options class with its sensor class automatically as soon as both modules have been imported - the user only ever creates the options instance and passes it to scene.add_sensor(...).
Automatic registration (the plugin mechanism)#
Sensors do not need to be registered manually. Defining a Sensor subclass parameterized with its options class is enough; the framework records the pairing the moment the class body runs.
That gives you two supported placements:
In-tree (built-in sensors) - options in
genesis/options/sensors/*.py, sensor ingenesis/engine/sensors/*.py. Both are imported through the package__init__already.Out-of-tree (third-party plugins) - put
MyOptionsandMySensorin sibling submodules of the same Python package:my_sensor_plugin/ __init__.py options.py # class MyOptions(SimpleSensorOptions["MySensor"]): ... sensor.py # class MySensor(SimpleSensor[MyOptions, MyMetadata]): ...
As long as your code imports
my_sensor_plugin.optionssomewhere before constructingMyOptions(), Genesis will lazily import the siblingmy_sensor_plugin.sensormodule on the first call toscene.add_sensor(MyOptions(...))and the pairing resolves transparently.
Picking the right base class#
Base |
When to use |
|---|---|
|
Almost always. Per-step pipeline (raw -> physics imperfections -> transform -> hardware imperfections -> post-process -> delay sampling). |
|
Same as above, but |
|
Camera-style sensors that render an RGB image lazily on |
|
Only when neither standard pipeline applies. Overriding at this level means implementing |
For mixins, the convention is:
KinematicSensorOptionsMixinfor sensors attached to aKinematicEntity(or anything kinematic-only).RigidSensorOptionsMixinfor sensors that require rigid-body physics (contact, IMU, tactile, …). Combine withSimpleSensorOptionsvia multiple inheritance.On the sensor side,
RigidSensorMixin/RigidSensorMetadataMixingive you a typedsolverfield and the links bookkeeping you typically need.
The hooks of SimpleSensor#
All hooks are @classmethods. They receive shared_metadata (the per-sensor-class state container) and the buffers they must populate. Hooks are called once per simulation step for the whole class at once - never per sensor instance, never per environment.
Required overrides#
_get_return_format(self) -> tuple[...]#
Instance method returning the shape of what read() returns. Shape is per-instance by design: sensor options may legitimately determine the returned shape (Raycaster.pattern.return_shape, Camera.res, Proximity.probe_local_pos, etc.).
def _get_return_format(self) -> tuple[int, ...]:
return (3,)
Conventions:
(N,)for a single per-sensor tensor ofNscalars.((3,), (3,), (3,))(tuple of tuples) for a multi-tensor return (NamedTuple data class). Must match the fields of theDataTyou specified in the generic parameter.
_get_cache_dtype(cls) -> torch.dtype#
Classmethod returning the dtype of what read() returns. Dtype is class-uniform: a single dtype shared by every instance of the sensor class. This is a load-bearing invariant of the manager - the per-class slice into the per-dtype intermediate buffer must be contiguous, so all instances of a class must share one dtype. If you need different dtypes for different instances, use two different sensor classes.
@classmethod
def _get_cache_dtype(cls) -> torch.dtype:
return gs.tc_float
The split between an instance method for shape and a classmethod for dtype is deliberate: it lets the manager resolve the per-class dtype without instantiating any sensor, while still allowing the per-instance shape to depend on options.
Optional overrides#
_get_intermediate_format(self) -> tuple[...]#
Instance method returning the shape of the pipeline-internal buffer (transform, physics imperfections, hardware imperfections all happen in this space; _post_process projects out of it into return space). Defaults to _get_return_format(). Override together with _post_process whenever your projection changes shape, or as a no-op acknowledgement when only _get_intermediate_dtype would otherwise differ.
def _get_intermediate_format(self) -> tuple[int, ...]:
return self._get_return_format() # no-op when shape coincides with return
_get_intermediate_dtype(cls) -> torch.dtype#
Classmethod returning the dtype of the pipeline-internal buffer. Defaults to _get_cache_dtype(). Override together with _post_process when the projection changes dtype (e.g. ContactSensor: float intermediate, bool return).
@classmethod
def _get_intermediate_dtype(cls) -> torch.dtype:
return gs.tc_float # float kernel output; bool projection in `_post_process`
When _get_intermediate_format and _get_intermediate_dtype both default, _post_process is identity, and no sensor in the class declares delay > 0 or history_length > 0 (the common case for most no-op sensors), the manager allocates one buffer and aliases the return cache as a view of the intermediate cache - no extra memory, no copy. Any of those triggers - overriding _post_process, non-zero delay, non-zero history - causes the manager to allocate a separate per-class return cache and a per-class return-space ring to back delay sampling and history reads.
uses_ring_pipeline (class attribute, ClassVar[bool])#
Class-level capability flag declaring whether the class participates in the ring-based per-step pipeline inside _update_shared_cache. The default is True, which is what Sensor exposes and SimpleSensor inherits unchanged: every sensor that uses the standard orchestrator needs the GT and measured timeline rings. Subclasses whose _update_shared_cache bypasses the rings entirely (built-in cameras handle rendering lazily on read and never touch either timeline) set this to False so the manager skips the paired allocation.
Set it on the class itself, not on the instance - the manager reads it once at scene-build time to decide ring allocation per sensor class. Changing it after build has no effect because allocation is already done.
class MyCustomSensor(Sensor[MyOptions, MyMetadata]):
uses_ring_pipeline: ClassVar[bool] = False # only if you implement `_update_shared_cache` from scratch
The runtime gating of imperfection contributions (has_any_noise, has_any_bias, etc.) is a separate concern handled inside _apply_hardware_imperfections; those flags are updated by the set_* setters and decide per-step which contributions cost any work. Ring allocation is binary: either the class uses the rings or it doesn’t.
Kernel-internal physics noise#
Some sensors have noise that is intrinsic to the physics computation - a single kernel pass must produce both the ground-truth value (the ideal signal) and the noised measured value, because the noise is sampled inside the kernel and modulates intermediate quantities (e.g. a probe-radius perturbation that affects which face the ray hits). Splitting compute into “GT first, then add noise” is impossible: the noise is not a post-hoc addition, it shapes the kernel’s branches.
Two supported routes:
Override
_update_current_timestep_dataonSimpleSensor. Your kernel writescurrent_ground_truth_data_T(the contiguous(cols, B)GT slice) andmeasured_data_timeline.at(0, copy=False)(the measured slot 0,(B, cols)) in one pass, and (when allocated)ground_truth_data_timeline.at(0, copy=False)(the GT slot 0). Because_apply_physics_imperfectionsis packed inside this hook, your override naturally fuses raw + physical-response noise. The rest of the SimpleSensor pipeline (_apply_transformon both branches, delay sampling,_apply_hardware_imperfections, eager_post_process) still runs on top. Recommended when you also want any of the standard pipeline pieces (imperfection parameters, delay/jitter,_post_processprojection).Derive from
Sensordirectly and override_update_shared_cache. Skips the SimpleSensor chain entirely. The override receives(metadata, gt_T, gt_timeline, measured_timeline, intermediate_cache)and is responsible for populating them. The manager handles_post_processprojection, return-space ring writes, and delay sampling after the hook returns. Use this when the sensor needs a fundamentally non-standard pipeline (e.g. cameras and renderers).
Camera-style sensors via BaseCameraSensor#
BaseCameraSensor is a Sensor-direct subclass that codifies the lazy-render-on-read pattern shared by every Genesis camera (RasterizerCameraSensor, RaytracerCameraSensor, BatchRendererCameraSensor). Use it as the base for any custom sensor that produces an image by rendering the scene rather than by reading physics-time signals each step.
Advantages over deriving from Sensor directly
Lazy render-on-read with per-step caching: multiple
read()calls in the same simulation step share a single render. No need to implement_update_shared_cache.Link attachment with
pos/lookat/up(or an explicitoffset_T): the camera follows aRigidLinkeach frame and hands you the world-space transform to apply to your renderer.An RGB output of shape
((h, w, 3),)and dtypetorch.uint8declared fromoptions.res, plus aread(envs_idx=...) -> CameraDatareturning aNamedTuple(rgb=...)(with the leading batch dim dropped whenn_envs == 0).The class is opted out of the ring pipeline (
uses_ring_pipeline = False), and__init__rejectsdelay > 0,jitter > 0, andhistory_length > 0so users cannot silently request features that this sensor type cannot honor.
What you must implement
Two hooks:
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 representation.
...
def _render_current_state(self) -> None:
# Render the scene from the current pose into the per-sensor slot of the per-class image cache.
# Called at most once per simulation step per camera.
...
See RasterizerCameraSensor for a complete worked example.
Limitations
Return is fixed to
((h, w, 3),)torch.uint8. For depth, segmentation, normals, or any non-RGB output, override_get_return_format/_get_cache_dtype(and adapt the cache backing store), or drop down to a bareSensorsubclass.The standard
SimpleSensorimperfection knobs (noise,bias,random_walk,resolution) are not available. Any sensor-imperfection model has to live inside_render_current_stateor in a_post_processoverride.history_length > 0is rejected. Multi-frame stacks must be assembled by the caller across successiveread()calls.
Returning a NamedTuple instead of a tensor#
For multi-tensor returns (IMU style), define a NamedTuple and parameterize the sensor class:
class IMUData(NamedTuple):
lin_acc: torch.Tensor
ang_vel: torch.Tensor
mag: torch.Tensor
class IMUSensor(SimpleSensor[IMU, IMUSharedMetadata, IMUData]):
def _get_return_format(self) -> tuple[tuple[int, ...], ...]:
# Shapes must match the NamedTuple field order.
return ((3,), (3,), (3,))
@classmethod
def _get_cache_dtype(cls) -> torch.dtype:
# Single dtype across all fields (class-uniform).
return gs.tc_float
The manager allocates a single contiguous slab of sum(shape_i) scalars per sensor and slices it on read; the public read() reconstructs and returns the NamedTuple.
Worked example - a minimal proximity sensor#
# my_plugin/options.py
from genesis.options.sensors.options import SimpleSensorOptions
from genesis.options.sensors.options import RigidSensorOptionsMixin
class MyProximity(
RigidSensorOptionsMixin["MyProximitySensor"],
SimpleSensorOptions["MyProximitySensor"],
):
max_range: float = 1.0
# my_plugin/sensor.py
from dataclasses import dataclass
import torch
import genesis as gs
from genesis.engine.sensors.base_sensor import (
SimpleSensor, SimpleSensorMetadata,
)
from genesis.engine.sensors.base_sensor import (
RigidSensorMetadataMixin, RigidSensorMixin,
)
from genesis.utils.misc import concat_with_tensor, make_tensor_field
from .options import MyProximity
@dataclass
class MyProximityMetadata(RigidSensorMetadataMixin, SimpleSensorMetadata):
max_range: torch.Tensor = make_tensor_field((0,))
class MyProximitySensor(
RigidSensorMixin[MyProximityMetadata],
SimpleSensor[MyProximity, MyProximityMetadata],
):
def build(self):
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,)
@classmethod
def _get_cache_dtype(cls) -> torch.dtype:
return gs.tc_float
@classmethod
def _update_raw_data(cls, 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) # (N, B)
That is enough for the sensor to be usable via scene.add_sensor(MyProximity(entity_idx=0, ...)). All of the imperfection plumbing (noise, bias, random walk, delay, jitter, history) is inherited from SimpleSensor and applied uniformly.
Canonical examples: what built-in sensors override#
To pick the right hook for your case, mirror the closest built-in sensor:
Every concrete sensor must implement _get_return_format (instance) and _get_cache_dtype (classmethod). The table below shows which additional hooks are overridden:
Built-in sensor |
|
|
|
|
|---|---|---|---|---|
|
yes (float kernel) |
- |
- |
bool threshold ( |
|
yes |
- |
- |
stateless clamp + deadband; shape/dtype preserved; no-op |
|
yes |
- |
yes (body-frame rotation; no filter) |
identity |
|
yes |
- |
- |
identity |
|
yes |
- |
- |
identity |
|
yes (raw temperature kernel) |
- |
yes (RC filter that reads |
identity |
|
yes |
- |
- |
identity |
Camera (any |
- |
- |
- |
identity. Derives from |
_apply_hardware_imperfections is inherited unchanged by every SimpleSensor-derived sensor - the out-of-the-box implementation already handles noise + bias + random_walk + resolution. Override only when your sensor needs a non-standard imperfection model.
Things to double-check#
Populate
raw_data_Tin place; do not rebind it. It is the implementer’s responsibility: assigningraw_data_T = something_newinside the override leaves the framework-owned buffer untouched and silently breaks the pipeline. Write viaraw_data_T.copy_(...),raw_data_T[...] = ..., or a kernel that takesraw_data_Tas its output argument.Reads are idempotent. Do not put state mutation inside
read(). Mutation belongs in the per-step hooks which the manager calls once per simulation step.Hooks are called once per class, not per instance or per env. Vectorize accordingly.
Shape is per-instance; dtype is class-uniform.
_get_return_format/_get_intermediate_formatare instance methods so options can affect the shape;_get_cache_dtype/_get_intermediate_dtypeare classmethods because every instance of a class must share one dtype.Overriding
_post_processrequires overriding_get_intermediate_formatand/or_get_intermediate_dtype. Even a no-op override is acceptable when shape and dtype both coincide with the return space - it is the explicit acknowledgement that the intermediate buffer is distinct.Recommended utilities.
concat_with_tensor,make_tensor_field, andtensor_to_arrayfromgenesis.utils.miscmatch the conventions used by every built-in sensor; reusing them keeps your sensor consistent with the rest of the codebase.