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 extrinsics specify the camera’s pose relative to its parent (e.g., the robot’s ee_link or 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:

  1. Camera sensor creation using CameraCfg

  2. Sensor attachment to a robot link or placement in the scene

  3. Camera configuration (intrinsics, extrinsics, clipping planes)

  4. Real-time visualization of color, depth, mask, and normal images

  5. Robot-sensor integration in a simulation loop

  6. 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.