Skip to main content
📷 Depth→Scan

ROS 2 Depth Image to LaserScan Guide 2026

depthimage_to_laserscan converts a depth camera's depth image into a virtual sensor_msgs/LaserScan — letting Nav2 use a RealSense D435, OAK-D, or Kinect for obstacle avoidance without a physical LiDAR.

How It Works

Depth Camera

/camera/depth/image_rect_raw
(sensor_msgs/Image, 16UC1)

depthimage_to_laserscan

Samples a horizontal strip
of pixels at scan_height

Virtual LaserScan

/scan
(sensor_msgs/LaserScan)

Nav2

Costmap obstacle layer
Path planning

Limitation: The virtual scan is a 2D horizontal slice — it misses obstacles above or below the sampling strip height. Use Nav2's 3D obstacle layer (VoxelLayer) with the full PointCloud2 for complete 3D obstacle detection.

Install

sudo apt install ros-jazzy-depthimage-to-laserscan
# or Humble:
sudo apt install ros-humble-depthimage-to-laserscan

# Also install depth camera drivers:
sudo apt install ros-jazzy-realsense2-camera  # Intel RealSense
sudo apt install ros-jazzy-depthai-ros        # Luxonis OAK-D

Basic Node Launch

# Command line (quick test):
ros2 run depthimage_to_laserscan depthimage_to_laserscan_node \
  --ros-args \
  -r depth:=/camera/depth/image_rect_raw \
  -r depth_camera_info:=/camera/depth/camera_info \
  -r scan:=/scan \
  -p scan_height:=1 \
  -p output_frame:=camera_depth_frame \
  -p range_min:=0.1 \
  -p range_max:=5.0

# In a launch file:
from launch_ros.actions import Node

depthimage_to_laserscan_node = Node(
    package="depthimage_to_laserscan",
    executable="depthimage_to_laserscan_node",
    name="depthimage_to_laserscan",
    remappings=[
        ("depth", "/camera/depth/image_rect_raw"),
        ("depth_camera_info", "/camera/depth/camera_info"),
        ("scan", "/scan"),
    ],
    parameters=[{
        "scan_height": 1,           # pixels tall to sample (1=single row at center)
        "output_frame": "camera_depth_frame",
        "range_min": 0.10,          # meters — minimum valid depth
        "range_max": 5.0,           # meters — maximum valid depth
    }],
)

Parameter Reference

ParameterDefaultNotes
scan_height1Height in pixels of the depth strip to sample. Increase (e.g. 5-10) to average out noise.
output_framecamera_depth_frameFrame ID for the output LaserScan. Must have a TF to base_link.
range_min0.45Minimum valid range in meters. Depths below this are reported as inf.
range_max10.0Maximum valid range. RealSense D435 reliable range: 0.1–6m. Set to 5.0 for safety.

Intel RealSense D435 Full Setup

# 1. Launch RealSense camera node
ros2 launch realsense2_camera rs_launch.py \
  enable_depth:=true \
  depth_width:=640 depth_height:=480 \
  depth_fps:=30.0 \
  enable_color:=false      # save bandwidth if not needed

# 2. Launch depthimage_to_laserscan
ros2 run depthimage_to_laserscan depthimage_to_laserscan_node \
  --ros-args \
  -r depth:=/camera/camera/depth/image_rect_raw \
  -r depth_camera_info:=/camera/camera/depth/camera_info \
  -r scan:=/scan \
  -p scan_height:=3 \
  -p output_frame:=camera_depth_optical_frame \
  -p range_min:=0.15 \
  -p range_max:=5.0

# 3. Verify scan is publishing
ros2 topic hz /scan  # should match depth_fps (30 Hz)
ros2 topic echo /scan --no-arr | head -10

TF Requirements for Nav2

Nav2 needs a TF chain from the scan's output_frame to base_link:

# In your URDF — add camera link relative to base_link:
<joint name="camera_joint" type="fixed">
  <parent link="base_link"/>
  <child link="camera_link"/>
  <origin xyz="0.1 0.0 0.2" rpy="0 0 0"/>
</joint>

<link name="camera_link"/>

<!-- RealSense SDK publishes camera_link → camera_depth_optical_frame via TF -->

# Verify the chain:
ros2 run tf2_tools view_frames
# Look for: base_link → camera_link → camera_depth_frame → camera_depth_optical_frame

# Or live check:
ros2 run tf2_ros tf2_echo base_link camera_depth_optical_frame

Combining Depth Scan + Real LiDAR

Use ira_laser_tools to merge multiple LaserScan topics into one:

sudo apt install ros-jazzy-ira-laser-tools

# Launch laser_scan_merger:
ros2 run ira_laser_tools laserscan_multi_merger \
  --ros-args \
  -p laserscan_topics:="/scan /scan_depth" \
  -p destination_frame:=base_link \
  -p cloud_destination_topic:=/merged_cloud \
  -p scan_destination_topic:=/scan_merged \
  -p scan_time_increment:=0.0

Nav2 config: In nav2_params.yaml, set the ObstacleLayer scan topic to /scan_merged to use the combined scan for obstacle avoidance.

Nav2 ObstacleLayer with Depth Scan

# In nav2_params.yaml — local costmap ObstacleLayer:
local_costmap:
  local_costmap:
    ros__parameters:
      plugins: ["obstacle_layer", "inflation_layer"]

      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        enabled: True
        observation_sources: scan depth_scan

        # Real LiDAR
        scan:
          topic: /scan
          data_type: LaserScan
          clearing: True
          marking: True
          raytrace_max_range: 10.0
          obstacle_max_range: 5.0

        # Virtual scan from depth camera
        depth_scan:
          topic: /scan_depth
          data_type: LaserScan
          clearing: False          # depth scan has too many false clears
          marking: True            # only mark obstacles, don't clear
          raytrace_max_range: 5.0
          obstacle_max_range: 4.0
          min_obstacle_height: 0.0
          max_obstacle_height: 2.0

Common Issues

Scan output is all 'inf' values

Check depth topic is publishing: ros2 topic hz /camera/depth/image_rect_raw. Verify range_min is not too small for the camera (RealSense D435 minimum reliable depth = 0.15m). Check encoding — must be 16UC1 or 32FC1.

TF error: 'Could not transform from camera_depth_optical_frame to map'

The camera_depth_optical_frame must be connected to base_link in your URDF. The RealSense node publishes camera_link → camera_depth_frame → camera_depth_optical_frame. Ensure your URDF has base_link → camera_link.

Nav2 costmap shows obstacles far from actual walls

Depth cameras have higher noise than LiDAR. Increase range_min (0.15→0.3). Increase scan_height (1→5) to average across more rows. Set marking=True but clearing=False in the ObstacleLayer depth_scan source.

Next Steps