Skip to content

Commit a7203ac

Browse files
[feat] Add RealSense multisensor example (#62)
* [feat] Add RealSense multisensor example Add a focused RealSense smoke-test script for exercising multisensor RGB-D plus IMU tracking with the D455. * [clean] Simplify RealSense multisensor example Remove temporary crash diagnostics so the new RealSense multisensor script reads like the other runnable examples. * [clean] Document RealSense multisensor modes Make the RealSense multisensor examples discoverable and add a simple switch for trying stereo+IMU through Multisensor mode. * [clean] Tighten RealSense multisensor IMU handling Use stream-typed IMU frames and narrower queue/error handling so expected RealSense cases are handled without hiding data-processing bugs.
1 parent 1b99376 commit a7203ac

4 files changed

Lines changed: 347 additions & 0 deletions

File tree

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Explore various examples to quickly get started with cuVSLAM using [Python API (
6969

7070
- **Multisensor Odometry (any-mix RGB / RGB-D, optional IMU)**
7171
- [Tartan Ground Dataset](multisensor/README.md)
72+
- [RealSense Live Camera](realsense/README.md#running-multisensor-odometry)
7273

7374
### SLAM Examples
7475

examples/realsense/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,25 @@ python3 run_rgbd.py
9898
You should see the following rerun visualization, displaying the camera trajectory along with the input RGB and depth images:
9999

100100
![Visualization Example](../assets/tutorial_realsense_rgbd.gif)
101+
102+
## Running Multisensor Odometry
103+
104+
Multisensor odometry uses the cuNLS-based `OdometryMode.Multisensor` pipeline. With a RealSense D455, the simplest
105+
live setup is RGB-D + IMU: the RGB stream provides visual features, the aligned depth stream provides metric depth, and
106+
the integrated IMU is registered through the same serialized tracker call path as the stereo inertial example.
107+
108+
To run RGB-D + IMU multisensor odometry, execute:
109+
110+
```bash
111+
python3 run_multisensor.py
112+
```
113+
114+
For a stereo + IMU multisensor smoke test using the same IR streams as the stereo inertial example, set
115+
`USE_MULTISENSOR_MODE = True` in `run_vio.py` and run:
116+
117+
```bash
118+
python3 run_vio.py
119+
```
120+
121+
> **Note:** Multisensor mode requires a cuNLS-enabled PyCuVSLAM build. The default source build enables cuNLS; if
122+
> cuVSLAM was configured with `-DUSE_CUNLS=OFF`, these multisensor examples are unavailable.
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# NVIDIA software released under the NVIDIA Community License is intended to be used to enable
4+
# the further development of AI and robotics technologies. Such software has been designed, tested,
5+
# and optimized for use with NVIDIA hardware, and this License grants permission to use the software
6+
# solely with such hardware.
7+
# Subject to the terms of this License, NVIDIA confirms that you are free to commercially use,
8+
# modify, and distribute the software with NVIDIA hardware. NVIDIA does not claim ownership of any
9+
# outputs generated using the software or derivative works thereof. Any code contributions that you
10+
# share with NVIDIA are licensed to NVIDIA as feedback under this License and may be incorporated
11+
# in future releases without notice or attribution.
12+
# By using, reproducing, modifying, distributing, performing, or displaying any portion or element
13+
# of the software or derivative works thereof, you agree to be bound by this License.
14+
15+
import queue
16+
import threading
17+
from collections import deque
18+
from contextlib import suppress
19+
from dataclasses import dataclass
20+
from typing import Optional
21+
22+
import numpy as np
23+
import pyrealsense2 as rs
24+
25+
import cuvslam as vslam
26+
from camera_utils import get_rs_camera, get_rs_imu
27+
from visualizer import RerunVisualizer
28+
29+
# Constants
30+
RESOLUTION = (640, 360)
31+
FPS = 30
32+
IMU_FREQUENCY_ACCEL = 200
33+
IMU_FREQUENCY_GYRO = 200
34+
WARMUP_FRAMES = 60
35+
IMAGE_JITTER_THRESHOLD_NS = 35 * 1e6 # 35ms in nanoseconds
36+
IMU_JITTER_THRESHOLD_NS = 6 * 1e6 # 6ms in nanoseconds
37+
IMU_QUEUE_MAX_SIZE = IMU_FREQUENCY_ACCEL * 5
38+
NUM_VIZ_CAMERAS = 2
39+
SHOW_GRAVITY = False
40+
41+
42+
@dataclass
43+
class ImuSample:
44+
"""Raw IMU sample buffered before registration with cuVSLAM."""
45+
46+
timestamp_ns: int
47+
linear_accelerations: tuple[float, float, float]
48+
angular_velocities: tuple[float, float, float]
49+
50+
51+
def configure_video_streams(config: rs.config) -> None:
52+
"""Enable RGB-D streams."""
53+
config.enable_stream(
54+
rs.stream.color, RESOLUTION[0], RESOLUTION[1], rs.format.bgr8, FPS
55+
)
56+
config.enable_stream(
57+
rs.stream.depth, RESOLUTION[0], RESOLUTION[1], rs.format.z16, FPS
58+
)
59+
60+
61+
def configure_motion_streams(config: rs.config) -> None:
62+
"""Enable RealSense IMU streams."""
63+
config.enable_stream(rs.stream.accel, rs.format.motion_xyz32f, IMU_FREQUENCY_ACCEL)
64+
config.enable_stream(rs.stream.gyro, rs.format.motion_xyz32f, IMU_FREQUENCY_GYRO)
65+
66+
67+
def setup_camera_parameters() -> tuple[dict[str, dict[str, object]], float]:
68+
"""Read RealSense intrinsics, extrinsics, and depth scale."""
69+
config = rs.config()
70+
configure_video_streams(config)
71+
configure_motion_streams(config)
72+
73+
pipeline = rs.pipeline()
74+
profile = pipeline.start(config)
75+
try:
76+
depth_sensor = profile.get_device().first_depth_sensor()
77+
depth_scale = depth_sensor.get_depth_scale()
78+
79+
color_profile = profile.get_stream(rs.stream.color).as_video_stream_profile()
80+
accel_profile = profile.get_stream(rs.stream.accel)
81+
82+
camera_params: dict[str, dict[str, object]] = {
83+
'color': {
84+
'intrinsics': color_profile.intrinsics
85+
},
86+
'imu': {
87+
'cam_from_imu': accel_profile.get_extrinsics_to(color_profile)
88+
}
89+
}
90+
finally:
91+
pipeline.stop()
92+
93+
return camera_params, depth_scale
94+
95+
96+
def get_rs_multisensor_rig(
97+
camera_params: dict[str, dict[str, object]]
98+
) -> vslam.Rig:
99+
"""Create a Multisensor rig for RGB-D + IMU on a D455."""
100+
rig = vslam.Rig()
101+
rig.cameras = [
102+
get_rs_camera(camera_params['color']['intrinsics'])
103+
]
104+
rig.imus = [get_rs_imu(camera_params['imu']['cam_from_imu'])]
105+
return rig
106+
107+
108+
def configure_emitter(video_pipe: rs.pipeline, video_config: rs.config) -> None:
109+
"""Enable the IR emitter for better depth quality."""
110+
pipeline_wrapper = rs.pipeline_wrapper(video_pipe)
111+
pipeline_profile = video_config.resolve(pipeline_wrapper)
112+
depth_sensor = pipeline_profile.get_device().first_depth_sensor()
113+
if depth_sensor.supports(rs.option.emitter_enabled):
114+
depth_sensor.set_option(rs.option.emitter_enabled, 1)
115+
116+
117+
def imu_thread(
118+
imu_queue: queue.Queue[ImuSample],
119+
motion_pipe: rs.pipeline,
120+
stop_event: threading.Event
121+
) -> None:
122+
"""Capture IMU samples into a queue without touching the cuVSLAM tracker."""
123+
prev_timestamp: Optional[int] = None
124+
drop_count = 0
125+
queue_drop_count = 0
126+
127+
try:
128+
while not stop_event.is_set():
129+
imu_frames = motion_pipe.wait_for_frames()
130+
accel_frame = imu_frames.first_or_default(rs.stream.accel)
131+
gyro_frame = imu_frames.first_or_default(rs.stream.gyro)
132+
if not accel_frame or not gyro_frame:
133+
continue
134+
current_timestamp = int(accel_frame.timestamp * 1e6)
135+
136+
if prev_timestamp is not None:
137+
timestamp_diff = current_timestamp - prev_timestamp
138+
if timestamp_diff < 0:
139+
continue
140+
if timestamp_diff > IMU_JITTER_THRESHOLD_NS:
141+
drop_count += 1
142+
if drop_count % 100 == 1:
143+
print(f"Warning: IMU drops detected ({drop_count} total, last gap: {timestamp_diff/1e6:.2f} ms)")
144+
145+
prev_timestamp = current_timestamp
146+
147+
accel_data = accel_frame.as_motion_frame().get_motion_data()
148+
gyro_data = gyro_frame.as_motion_frame().get_motion_data()
149+
sample = ImuSample(
150+
timestamp_ns=current_timestamp,
151+
linear_accelerations=(accel_data.x, accel_data.y, accel_data.z),
152+
angular_velocities=(gyro_data.x, gyro_data.y, gyro_data.z)
153+
)
154+
155+
try:
156+
imu_queue.put_nowait(sample)
157+
except queue.Full:
158+
queue_drop_count += 1
159+
with suppress(queue.Empty):
160+
imu_queue.get_nowait()
161+
imu_queue.put_nowait(sample)
162+
if queue_drop_count % 100 == 1:
163+
print(f"Warning: IMU queue overflow ({queue_drop_count} dropped samples)")
164+
except RuntimeError as e:
165+
if not stop_event.is_set():
166+
print(f"IMU thread error: {e}")
167+
168+
169+
def register_imu_until(
170+
tracker: vslam.Tracker,
171+
imu_queue: queue.Queue[ImuSample],
172+
pending_imu: deque[ImuSample],
173+
timestamp_ns: int,
174+
last_tracker_timestamp_ns: Optional[int]
175+
) -> Optional[int]:
176+
"""Register queued IMU samples up to the image timestamp."""
177+
while True:
178+
try:
179+
pending_imu.append(imu_queue.get_nowait())
180+
except queue.Empty:
181+
break
182+
183+
while pending_imu and pending_imu[0].timestamp_ns <= timestamp_ns:
184+
sample = pending_imu.popleft()
185+
if last_tracker_timestamp_ns is not None and sample.timestamp_ns < last_tracker_timestamp_ns:
186+
continue
187+
imu_measurement = vslam.ImuMeasurement()
188+
imu_measurement.timestamp_ns = sample.timestamp_ns
189+
imu_measurement.linear_accelerations = sample.linear_accelerations
190+
imu_measurement.angular_velocities = sample.angular_velocities
191+
tracker.register_imu_measurement(0, imu_measurement)
192+
last_tracker_timestamp_ns = sample.timestamp_ns
193+
return last_tracker_timestamp_ns
194+
195+
196+
def main() -> None:
197+
"""Run RealSense Multisensor tracking with RGB-D and IMU."""
198+
camera_params, depth_scale = setup_camera_parameters()
199+
rig = get_rs_multisensor_rig(camera_params)
200+
201+
multisensor_settings = vslam.Tracker.OdometryMultisensorSettings(
202+
depth_camera_ids=[0],
203+
depth_scale_factor=1 / depth_scale,
204+
enable_depth_stereo_tracking=True
205+
)
206+
cfg = vslam.Tracker.OdometryConfig(
207+
async_sba=False,
208+
enable_final_landmarks_export=True,
209+
enable_observations_export=True,
210+
odometry_mode=vslam.Tracker.OdometryMode.Multisensor,
211+
multisensor_settings=multisensor_settings,
212+
rectified_stereo_camera=False
213+
)
214+
tracker = vslam.Tracker(rig, cfg)
215+
216+
video_pipe = rs.pipeline()
217+
video_config = rs.config()
218+
configure_video_streams(video_config)
219+
configure_emitter(video_pipe, video_config)
220+
align = rs.align(rs.stream.color)
221+
222+
motion_pipe = rs.pipeline()
223+
motion_config = rs.config()
224+
configure_motion_streams(motion_config)
225+
226+
imu_queue = queue.Queue(maxsize=IMU_QUEUE_MAX_SIZE)
227+
pending_imu: deque[ImuSample] = deque()
228+
stop_event = threading.Event()
229+
visualizer = RerunVisualizer(
230+
num_viz_cameras=NUM_VIZ_CAMERAS,
231+
image_size=RESOLUTION,
232+
show_gravity=SHOW_GRAVITY
233+
)
234+
235+
motion_pipe.start(motion_config)
236+
video_pipe.start(video_config)
237+
imu_thread_obj = threading.Thread(
238+
target=imu_thread,
239+
args=(imu_queue, motion_pipe, stop_event),
240+
daemon=True
241+
)
242+
imu_thread_obj.start()
243+
244+
frame_id = 0
245+
prev_timestamp: Optional[int] = None
246+
last_tracker_timestamp: Optional[int] = None
247+
trajectory: list[np.ndarray] = []
248+
249+
try:
250+
while True:
251+
frames = video_pipe.wait_for_frames()
252+
aligned_frames = align.process(frames)
253+
254+
color_frame = aligned_frames.get_color_frame()
255+
depth_frame = aligned_frames.get_depth_frame()
256+
if not color_frame or not depth_frame:
257+
print("Warning: missing color or depth frame")
258+
continue
259+
260+
timestamp = int(color_frame.timestamp * 1e6)
261+
if prev_timestamp is not None:
262+
timestamp_diff = timestamp - prev_timestamp
263+
if timestamp_diff > IMAGE_JITTER_THRESHOLD_NS:
264+
print(
265+
f"Warning: Camera stream message drop: timestamp gap "
266+
f"({timestamp_diff/1e6:.2f} ms) exceeds threshold "
267+
f"{IMAGE_JITTER_THRESHOLD_NS/1e6:.2f} ms"
268+
)
269+
270+
frame_id += 1
271+
if frame_id <= WARMUP_FRAMES:
272+
prev_timestamp = timestamp
273+
continue
274+
275+
images = [
276+
np.asanyarray(color_frame.get_data())
277+
]
278+
depths = [np.asanyarray(depth_frame.get_data())]
279+
280+
last_tracker_timestamp = register_imu_until(
281+
tracker, imu_queue, pending_imu, timestamp, last_tracker_timestamp
282+
)
283+
odom_pose_estimate, _ = tracker.track(timestamp, images=images, depths=depths)
284+
last_tracker_timestamp = timestamp
285+
prev_timestamp = timestamp
286+
287+
odom_pose_with_cov = odom_pose_estimate.world_from_rig
288+
if odom_pose_with_cov is None:
289+
print(f"Tracking failed at frame {frame_id}")
290+
continue
291+
292+
odom_pose = odom_pose_with_cov.pose
293+
trajectory.append(odom_pose.translation)
294+
observations = tracker.get_last_observations(0)
295+
gravity = tracker.get_last_gravity() if SHOW_GRAVITY else None
296+
if gravity is not None:
297+
gravity = np.asarray(gravity, dtype=np.float32)
298+
299+
visualizer.visualize_frame(
300+
frame_id=frame_id,
301+
images=[images[0], depths[0]],
302+
pose=odom_pose,
303+
observations_main_cam=[observations, observations],
304+
trajectory=trajectory,
305+
timestamp=timestamp,
306+
gravity=gravity
307+
)
308+
309+
except KeyboardInterrupt:
310+
print("Stopping Multisensor tracking...")
311+
finally:
312+
stop_event.set()
313+
motion_pipe.stop()
314+
video_pipe.stop()
315+
316+
317+
if __name__ == "__main__":
318+
main()

examples/realsense/run_vio.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
IMU_JITTER_THRESHOLD_NS = 6 * 1e6 # 6ms in nanoseconds
3535
IMU_QUEUE_MAX_SIZE = IMU_FREQUENCY_ACCEL * 5
3636
SHOW_GRAVITY = False
37+
USE_MULTISENSOR_MODE = False
3738

3839

3940
@dataclass
@@ -286,6 +287,11 @@ def main() -> None:
286287
odometry_mode=vslam.Tracker.OdometryMode.Inertial,
287288
rectified_stereo_camera=True
288289
)
290+
if USE_MULTISENSOR_MODE:
291+
cfg.odometry_mode = vslam.Tracker.OdometryMode.Multisensor
292+
cfg.multisensor_settings = vslam.Tracker.OdometryMultisensorSettings(
293+
depth_camera_ids=[]
294+
)
289295

290296
# Create rig using utility function
291297
rig = get_rs_vio_rig(camera_params)

0 commit comments

Comments
 (0)