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
| Parameter | Default | Notes |
|---|---|---|
| scan_height | 1 | Height in pixels of the depth strip to sample. Increase (e.g. 5-10) to average out noise. |
| output_frame | camera_depth_frame | Frame ID for the output LaserScan. Must have a TF to base_link. |
| range_min | 0.45 | Minimum valid range in meters. Depths below this are reported as inf. |
| range_max | 10.0 | Maximum 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.0Common 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
- → Nav2 Costmap2D guide — configure the ObstacleLayer and VoxelLayer that consume the virtual scan
- → slam_toolbox guide — use the virtual LaserScan to build maps without a physical LiDAR
- → ROS 2 image_transport guide — compress depth images for multi-robot or remote navigation setups