Simulating a Camera Sensor#
This tutorial demonstrates how to create and simulate a camera sensor attached to a robot using SimulationManager. You will learn how to configure a camera, attach it to the robot’s end-effector, and visualize the sensor’s output during simulation.
Source Code#
The code for this tutorial is in scripts/tutorials/sim/create_sensor.py.
Show code for create_sensor.py
1# ----------------------------------------------------------------------------
2# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15# ----------------------------------------------------------------------------
16
17"""
18This script demonstrates how to create and simulate a camera sensor attached to a robot using SimulationManager.
19It shows how to configure a camera sensor, attach it to the robot's end-effector, and visualize the sensor's output during simulation.
20"""
21
22from __future__ import annotations
23
24import argparse
25from embodichain.cli.sim import add_sim_args_to_parser
26
27
28def build_parser() -> argparse.ArgumentParser:
29 """Build CLI options without initializing simulation resources."""
30 parser = argparse.ArgumentParser(
31 description="Create and simulate a robot in SimulationManager"
32 )
33 add_sim_args_to_parser(parser)
34 parser.add_argument(
35 "--attach_sensor",
36 action="store_true",
37 help="Attach sensor to robot end-effector",
38 )
39 parser.add_argument(
40 "--steps",
41 type=int,
42 default=0,
43 help="Stop after this many simulation steps; zero runs until Ctrl+C.",
44 )
45 return parser
46
47
48if __name__ == "__main__":
49 # Parse before importing optional simulation/planning dependencies.
50 _cli_args = build_parser().parse_args()
51
52
53import numpy as np
54import torch
55import cv2
56
57torch.set_printoptions(precision=4, sci_mode=False)
58
59from scipy.spatial.transform import Rotation as R
60
61from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
62from embodichain.lab.visualization import visualization_cfg_from_args
63from embodichain.lab.sim.sensors import Camera, CameraCfg
64from embodichain.lab.sim.objects import Robot
65from embodichain.lab.sim.cfg import (
66 RenderCfg,
67 physics_cfg_for_backend,
68 JointDrivePropertiesCfg,
69 RobotCfg,
70 URDFCfg,
71 RigidObjectCfg,
72)
73from embodichain.lab.sim.shapes import CubeCfg
74from embodichain.data import get_data_path
75
76ACTION_SWITCH_INTERVAL = 100
77ACTION_CYCLE_STEPS = 2 * ACTION_SWITCH_INTERVAL
78
79
80def mask_to_color_map(mask, user_ids, fix_seed=True):
81 """
82 Convert instance mask into color map.
83 :param mask: Instance mask map.
84 :param user_ids: List of unique user IDs in the mask.
85 :return: Color map.
86 """
87 # Create a blank RGB image
88 color_map = np.zeros((mask.shape[0], mask.shape[1], 3), dtype=np.uint8)
89
90 # Generate deterministic colors based on user_id values
91 colors = []
92 for user_id in user_ids:
93 # Use the user_id as seed to generate deterministic color
94 np.random.seed(user_id)
95 color = np.random.choice(range(256), size=3)
96 colors.append(color)
97
98 for idx, color in enumerate(colors):
99 # Assign color to the instances of each class
100 color_map[mask == user_ids[idx]] = color
101
102 return color_map
103
104
105def main(args: argparse.Namespace | None = None) -> None:
106 """Main function to demonstrate robot sensor simulation."""
107
108 # Parse command line arguments
109 parser = build_parser()
110 if args is None:
111 args = parser.parse_args()
112 # Initialize simulation
113 print("Creating simulation...")
114 config = SimulationManagerCfg(
115 headless=True,
116 device=args.device,
117 arena_space=3.0,
118 render_cfg=RenderCfg(renderer=args.renderer),
119 physics_cfg=physics_cfg_for_backend(args.physics),
120 physics_dt=1.0 / 100.0,
121 num_envs=args.num_envs,
122 visualization=visualization_cfg_from_args(args),
123 )
124 sim = SimulationManager(config)
125
126 # Create robot configuration
127 robot = create_robot(sim)
128
129 # Add a cube to the scene
130 cube_cfg = RigidObjectCfg(
131 uid="cube",
132 shape=CubeCfg(size=[0.05, 0.05, 0.05]), # Use CubeCfg for a cube
133 init_pos=[1.2, -0.2, 0.1],
134 init_rot=[0, 0, 0],
135 )
136 sim.add_rigid_object(cfg=cube_cfg)
137
138 # Materialize all physical assets before reading robot metadata or
139 # constructing render-only sensors.
140 sim.prepare()
141 print(f"Robot created successfully with {robot.dof} joints")
142
143 sensor = create_sensor(sim, args)
144
145 # Open visualization window if not headless
146 if not args.headless:
147 sim.open_window()
148
149 # Run simulation loop
150 run_simulation(
151 sim,
152 robot,
153 sensor,
154 use_viser=args.viser,
155 max_steps=args.steps,
156 )
157
158
159def create_sensor(sim: SimulationManager, args):
160 # intrinsics params
161 intrinsics = (600, 600, 320.0, 240.0)
162 width = 640
163 height = 480
164
165 # extrinsics params
166 pos = [0.09, 0.05, 0.04]
167 # CameraCfg uses xyzw; this rotation preserves the intended wrist-camera view.
168 quat = R.from_euler("xyz", [180, -45, 35], degrees=True).as_quat().tolist()
169
170 # If attach_sensor is True, attach to robot end-effector; otherwise, place it in the scene
171 if args.attach_sensor:
172 parent = "ee_link"
173 else:
174 parent = None
175 pos = [1.2, -0.2, 1.5]
176 quat = R.from_euler("xyz", [0, 180, 0], degrees=True).as_quat().tolist()
177
178 # create camera sensor and attach to robot end-effector
179 camera: Camera = sim.add_sensor(
180 sensor_cfg=CameraCfg(
181 width=width,
182 height=height,
183 intrinsics=intrinsics,
184 extrinsics=CameraCfg.ExtrinsicsCfg(
185 parent=parent,
186 pos=pos,
187 quat=quat,
188 ),
189 near=0.01,
190 far=10.0,
191 enable_color=True,
192 enable_depth=True,
193 enable_mask=True,
194 enable_normal=True,
195 )
196 )
197 return camera
198
199
200def create_robot(sim):
201 """Create and configure a robot in the simulation."""
202
203 print("Loading robot...")
204
205 # Get SR5 URDF path
206 sr5_urdf_path = get_data_path("Rokae/SR5/SR5.urdf")
207
208 # Get hand URDF path
209 hand_urdf_path = get_data_path(
210 "BrainCoHandRevo1/BrainCoLeftHand/BrainCoLeftHand.urdf"
211 )
212
213 # Define control parts for the robot
214 # Joint names in control_parts can be regex patterns
215 CONTROL_PARTS = {
216 "arm": [
217 "joint[1-6]", # Matches JOINT1, JOINT2, ..., JOINT6
218 ],
219 "eef": ["LEFT_.*"], # Matches all joints starting with L_
220 }
221
222 # Define transformation for hand attachment
223 hand_attach_xpos = np.eye(4)
224 hand_attach_xpos[:3, :3] = R.from_rotvec([90, 0, 0], degrees=True).as_matrix()
225 hand_attach_xpos[2, 3] = 0.02
226
227 cfg = RobotCfg(
228 uid="sr5_with_brainco",
229 urdf_cfg=URDFCfg(
230 components=[
231 {
232 "component_type": "arm",
233 "urdf_path": sr5_urdf_path,
234 },
235 {
236 "component_type": "hand",
237 "urdf_path": hand_urdf_path,
238 "transform": hand_attach_xpos,
239 },
240 ]
241 ),
242 control_parts=CONTROL_PARTS,
243 joint_drive_props=JointDrivePropertiesCfg(
244 drive_type="force",
245 stiffness={"joint[1-6]": 1e4, "LEFT_.*": 1e3},
246 damping={"joint[1-6]": 1.5e3, "LEFT_.*": 1e2},
247 max_effort={"joint[1-6]": 1e4, "LEFT_.*": 1e4},
248 ),
249 )
250
251 # Add robot to simulation
252 robot: Robot = sim.add_robot(cfg=cfg)
253
254 return robot
255
256
257def get_sensor_image(camera: Camera, headless=False, step_count=0):
258 """
259 Get color, depth, mask, and normals views from the camera,
260 and visualize them in a 2x2 grid (or save if headless).
261 """
262 import matplotlib.pyplot as plt
263
264 camera.update()
265 data = camera.get_data()
266 # Get four views
267 rgba = data["color"].cpu().numpy()[0, :, :, :3] # (H, W, 3)
268 depth = data["depth"].squeeze().cpu().numpy() # (H, W)
269 mask = data["mask"].squeeze().cpu().numpy() # (H, W)
270 normals = data["normal"].cpu().numpy()[0] # (H, W, 3)
271
272 # Normalize for visualization
273 depth_vis = (depth - depth.min()) / (np.ptp(depth) + 1e-8)
274 depth_vis = (depth_vis * 255).astype("uint8")
275 mask_vis = mask_to_color_map(mask, user_ids=np.unique(mask))
276 normals_vis = ((normals + 1) / 2 * 255).astype("uint8")
277
278 # Prepare titles and images for display
279 titles = ["Color", "Depth", "Mask", "Normals"]
280 images = [
281 cv2.cvtColor(rgba, cv2.COLOR_RGB2BGR),
282 cv2.cvtColor(depth_vis, cv2.COLOR_GRAY2BGR),
283 mask_vis,
284 cv2.cvtColor(normals_vis, cv2.COLOR_RGB2BGR),
285 ]
286
287 if not headless:
288 # Concatenate images for 2x2 grid display using OpenCV
289 top = np.hstack([images[0], images[1]])
290 bottom = np.hstack([images[2], images[3]])
291 grid = np.vstack([top, bottom])
292 cv2.imshow("Sensor Views (Color / Depth / Mask / Normals)", grid)
293 cv2.waitKey(1)
294 else:
295 # Save the 2x2 grid as an image using matplotlib
296 fig, axs = plt.subplots(2, 2, figsize=(10, 8))
297 for ax, img, title in zip(axs.flatten(), images, titles):
298 ax.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
299 ax.set_title(title)
300 ax.axis("off")
301 plt.tight_layout()
302 plt.savefig(f"sensor_views_{step_count}.png")
303 plt.close(fig)
304
305
306def run_simulation(
307 sim: SimulationManager,
308 robot: Robot,
309 camera: Camera,
310 *,
311 use_viser: bool = False,
312 max_steps: int = 0,
313) -> None:
314 """Run the simulation loop with robot and camera sensor control."""
315
316 print("Starting simulation...")
317 print("Robot will move through different poses")
318 print("Press Ctrl+C to stop")
319
320 step_count = 0
321
322 arm_joint_ids = robot.get_joint_ids("arm")
323 # Define some target joint positions for demonstration
324
325 arm_position1 = (
326 torch.tensor(
327 [0.0, 0.5, -1.5, 0.3, -0.5, 0], dtype=torch.float32, device=sim.device
328 )
329 .unsqueeze_(0)
330 .repeat(sim.num_envs, 1)
331 )
332
333 arm_position2 = (
334 torch.tensor(
335 [0.0, 0.5, -1.5, -0.3, -0.5, 0], dtype=torch.float32, device=sim.device
336 )
337 .unsqueeze_(0)
338 .repeat(sim.num_envs, 1)
339 )
340
341 try:
342 while True:
343 # Update physics
344 sim.update(step=1)
345 cycle_step = step_count % ACTION_CYCLE_STEPS
346
347 if cycle_step == 0:
348 robot.set_qpos(qpos=arm_position1, joint_ids=arm_joint_ids)
349 print(f"Moving to arm position 1")
350
351 # Refresh and get image from sensor
352 if not use_viser:
353 get_sensor_image(camera)
354
355 if cycle_step == ACTION_SWITCH_INTERVAL:
356 robot.set_qpos(qpos=arm_position2, joint_ids=arm_joint_ids)
357 print(f"Moving to arm position 2")
358
359 # Refresh and get image from sensor
360 if not use_viser:
361 get_sensor_image(camera)
362
363 step_count += 1
364 if max_steps > 0 and step_count >= max_steps:
365 print(f"Reached {max_steps} simulation steps")
366 break
367
368 except KeyboardInterrupt:
369 print("Stopping simulation...")
370 finally:
371 print("Cleaning up...")
372 sim.destroy()
373
374
375if __name__ == "__main__":
376 main(_cli_args)
Overview#
This tutorial builds on the basic robot simulation example. If you are not familiar with robot simulation in SimulationManager, please read the Simulating a Robot tutorial first.
1. Sensor Creation and Attachment#
The camera sensor is created using CameraCfg and can be attached to the robot’s end-effector or placed freely in the scene. The attachment is controlled by the --attach_sensor argument.
def create_sensor(sim: SimulationManager, args):
# intrinsics params
intrinsics = (600, 600, 320.0, 240.0)
width = 640
height = 480
# extrinsics params
pos = [0.09, 0.05, 0.04]
# CameraCfg uses xyzw; this rotation preserves the intended wrist-camera view.
quat = R.from_euler("xyz", [180, -45, 35], degrees=True).as_quat().tolist()
# If attach_sensor is True, attach to robot end-effector; otherwise, place it in the scene
if args.attach_sensor:
parent = "ee_link"
else:
parent = None
pos = [1.2, -0.2, 1.5]
quat = R.from_euler("xyz", [0, 180, 0], degrees=True).as_quat().tolist()
# create camera sensor and attach to robot end-effector
camera: Camera = sim.add_sensor(
sensor_cfg=CameraCfg(
width=width,
height=height,
intrinsics=intrinsics,
extrinsics=CameraCfg.ExtrinsicsCfg(
parent=parent,
pos=pos,
quat=quat,
),
near=0.01,
far=10.0,
enable_color=True,
enable_depth=True,
enable_mask=True,
enable_normal=True,
)
)
return camera
The camera’s intrinsics (focal lengths and principal point) and resolution are set.
The
extrinsicsspecify the camera’s pose relative to its parent (e.g., the robot’see_linkor the world).The camera is added to the simulation with
sim.add_sensor().
2. Visualizing Sensor Output#
The function get_sensor_image retrieves and visualizes the camera’s color, depth, mask, and normal images. In GUI mode, images are shown in a 2x2 grid using OpenCV. In headless mode, images are saved to disk.
def get_sensor_image(camera: Camera, headless=False, step_count=0):
"""
Get color, depth, mask, and normals views from the camera,
and visualize them in a 2x2 grid (or save if headless).
"""
import matplotlib.pyplot as plt
camera.update()
data = camera.get_data()
# Get four views
rgba = data["color"].cpu().numpy()[0, :, :, :3] # (H, W, 3)
depth = data["depth"].squeeze().cpu().numpy() # (H, W)
mask = data["mask"].squeeze().cpu().numpy() # (H, W)
normals = data["normal"].cpu().numpy()[0] # (H, W, 3)
# Normalize for visualization
depth_vis = (depth - depth.min()) / (np.ptp(depth) + 1e-8)
depth_vis = (depth_vis * 255).astype("uint8")
mask_vis = mask_to_color_map(mask, user_ids=np.unique(mask))
normals_vis = ((normals + 1) / 2 * 255).astype("uint8")
# Prepare titles and images for display
titles = ["Color", "Depth", "Mask", "Normals"]
images = [
cv2.cvtColor(rgba, cv2.COLOR_RGB2BGR),
cv2.cvtColor(depth_vis, cv2.COLOR_GRAY2BGR),
mask_vis,
cv2.cvtColor(normals_vis, cv2.COLOR_RGB2BGR),
]
if not headless:
# Concatenate images for 2x2 grid display using OpenCV
top = np.hstack([images[0], images[1]])
bottom = np.hstack([images[2], images[3]])
grid = np.vstack([top, bottom])
cv2.imshow("Sensor Views (Color / Depth / Mask / Normals)", grid)
cv2.waitKey(1)
else:
# Save the 2x2 grid as an image using matplotlib
fig, axs = plt.subplots(2, 2, figsize=(10, 8))
for ax, img, title in zip(axs.flatten(), images, titles):
ax.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
ax.set_title(title)
ax.axis("off")
plt.tight_layout()
plt.savefig(f"sensor_views_{step_count}.png")
plt.close(fig)
The camera is updated to capture the latest data.
Four types of images are visualized: color, depth, mask, and normals.
Images are displayed in a window or saved as PNG files depending on the mode.
3. Simulation Loop#
The simulation loop moves the robot through different arm poses and periodically updates and visualizes the sensor output.
def run_simulation(
sim: SimulationManager,
robot: Robot,
camera: Camera,
*,
use_viser: bool = False,
max_steps: int = 0,
) -> None:
"""Run the simulation loop with robot and camera sensor control."""
print("Starting simulation...")
print("Robot will move through different poses")
print("Press Ctrl+C to stop")
step_count = 0
arm_joint_ids = robot.get_joint_ids("arm")
# Define some target joint positions for demonstration
arm_position1 = (
torch.tensor(
[0.0, 0.5, -1.5, 0.3, -0.5, 0], dtype=torch.float32, device=sim.device
)
.unsqueeze_(0)
.repeat(sim.num_envs, 1)
)
arm_position2 = (
torch.tensor(
[0.0, 0.5, -1.5, -0.3, -0.5, 0], dtype=torch.float32, device=sim.device
)
.unsqueeze_(0)
.repeat(sim.num_envs, 1)
)
try:
while True:
# Update physics
sim.update(step=1)
cycle_step = step_count % ACTION_CYCLE_STEPS
if cycle_step == 0:
robot.set_qpos(qpos=arm_position1, joint_ids=arm_joint_ids)
print(f"Moving to arm position 1")
# Refresh and get image from sensor
if not use_viser:
get_sensor_image(camera)
if cycle_step == ACTION_SWITCH_INTERVAL:
robot.set_qpos(qpos=arm_position2, joint_ids=arm_joint_ids)
print(f"Moving to arm position 2")
# Refresh and get image from sensor
if not use_viser:
get_sensor_image(camera)
step_count += 1
if max_steps > 0 and step_count >= max_steps:
print(f"Reached {max_steps} simulation steps")
break
except KeyboardInterrupt:
print("Stopping simulation...")
finally:
print("Cleaning up...")
sim.destroy()
The robot alternates between two arm positions.
After each movement, the sensor image is refreshed and visualized.
Running the Example#
To run the sensor simulation script:
python scripts/tutorials/sim/create_sensor.py
You can customize the simulation with the following command-line options:
# Use GPU physics
python scripts/tutorials/sim/create_sensor.py --device cuda
# Simulate multiple environments
python scripts/tutorials/sim/create_sensor.py --num_envs 4
# Run in headless mode (no GUI, images saved to disk)
python scripts/tutorials/sim/create_sensor.py --headless
# View camera frustums and all sensor RGB previews in Viser
python scripts/tutorials/sim/create_sensor.py --viser
# Select a renderer backend
python scripts/tutorials/sim/create_sensor.py --renderer rt
# Attach the camera to the robot end-effector
python scripts/tutorials/sim/create_sensor.py --attach_sensor
With --viser, use the browser’s Cameras panel to select the environment
and camera frustum. Its expanded RGB previews folder shows every camera
in that environment at once, with separate Record cameras and Sensor
cameras subfolders. The previews default to 2 FPS and can be changed with
--viser-image-fps. Depth, masks, and normals remain available through the
sensor API but are not currently shown in the Viser image panel.
Key Features Demonstrated#
This tutorial demonstrates:
Camera sensor creation using
CameraCfgSensor attachment to a robot link or placement in the scene
Camera configuration (intrinsics, extrinsics, clipping planes)
Real-time visualization of color, depth, mask, and normal images
Robot-sensor integration in a simulation loop
Browser camera inspection with a selected frustum and all low-frequency RGB previews
Next Steps#
After completing this tutorial, you can explore:
Using other sensor types (e.g., stereo cameras, force sensors)
Recording sensor data for offline analysis
Integrating sensor feedback into robot control or learning algorithms
Configuring Browser visualization with Viser for remote camera inspection
This tutorial provides a foundation for integrating perception into robotic simulation scenarios with SimulationManager.