The isaac-slam service is SkyCore’s GPS-denied navigation brain. It runs NVIDIA Isaac ROS Visual SLAM on an Intel RealSense stereo-infrared + IMU camera, converts the visual-inertial odometry into a MAVLink vision pose, and feeds that pose directly into the ArduPilot EKF so the vehicle can hold position and navigate without GPS — indoors, in tunnels, under bridges, or anywhere the satellites drop out. The same service also builds a 3D nvblox map that can be saved, reloaded, and turned into a live obstacle-distance feed. This is an opt-in, GPU-bound service (Compose profile slam, runtime: nvidia, network_mode: host) that is not part of the default COMPOSE_PROFILES. It runs on the Jetson alongside core, mavproxy, and ws_proxy.
isaac-slam is the one on-drone service that does not talk to the flight controller through the mavp2p UDP fabric. It reads vehicle state (/mavros/state) over ROS 2 DDS and writes vision pose to the FC over a direct serial MAVLink link. See the serial-port caveat below.

The end-to-end pipeline

StageComponentFile
SensorRealSense D435, dual mono infra + gyro/accel fusedconfigs/launch/isaac_ros_visual_slam_realsense.launch.py
VIOisaac_ros_visual_slam VisualSlamNode (IMU fusion on)same launch file
Mappingnvblox_node (static occupancy)services/navigation/slam_service.py:400
Bridgepose_bridge_with_covariance ROS 2 nodeservices/navigation/pose_bridge_with_covariance.py
ObstaclesObstacleProcessor decorator (opt-in)services/navigation/nvblox_obstacle_bridge.py
OrchestrationSlamService → subprocess launchesservices/navigation/slam_service.py
(All paths are under docker/issac-slam/isaac_ros-dev/src/isaac_ros_common/docker/ — note the directory is spelled issac-slam on disk while the Compose service and image use isaac.)

RealSense + Visual SLAM configuration

The camera is brought up with both infrared streams (color and depth disabled) plus the IMU, and the Visual SLAM node is configured for stereo + IMU fusion:
configs/launch/isaac_ros_visual_slam_realsense.launch.py
# RealSense
"enable_infra1": True, "enable_infra2": True,
"enable_color": False, "enable_depth": False,
"depth_module.emitter_enabled": 0,          # emitter OFF (stereo, not active depth)
"depth_module.profile": "640x360x90",       # 90 FPS mono pair
"enable_gyro": True, "enable_accel": True,
"gyro_fps": 200, "accel_fps": 200, "unite_imu_method": 2,

# VisualSlamNode
"rectified_images": True,
"enable_imu_fusion": True,
"base_frame": "camera_link",
"imu_frame": "camera_gyro_optical_frame",
The node publishes its tracked pose on /visual_slam/tracking/vo_pose (a geometry_msgs/PoseStamped in an ENU frame), which the pose bridge subscribes to. SlamService also watches /visual_slam/status and /odom as liveness signals and republishes a /diagnostics heartbeat.
At runtime SlamService._launch_isaac_slam() starts the camera + SLAM + nvblox together via ros2 launch nvblox_examples_bringup realsense_example.launch.py. The standalone isaac_ros_visual_slam_realsense.launch.py shown above is the canonical parameter reference for the RealSense/VSLAM nodes; the slam_launch_file value in _load_config() (/home/isaac_ros_visual_slam_realsense.launch.py) is declared but not what actually launches SLAM. Don’t assume it is the live launch path.

The pose bridge

pose_bridge_with_covariance.py is the heart of GPS-denied nav. On each incoming SLAM pose it:
  1. Transforms ENU → NED (PoseTransformer.transform_enu_to_ned).
  2. Estimates velocity, classifies a movement mode, and computes adaptive covariance.
  3. Rate-limits to send_rate_hz (default 30 Hz) and sends a VISION_POSITION_ESTIMATE over the direct MAVLink serial link.
It also runs background timers: a 1 Hz HEARTBEAT (as MAV_COMP_ID_VISUAL_INERTIAL_ODOMETRY), a 0.1 Hz SYSTEM_TIME clock sync, a one-shot EKF-origin set 10 s after the first pose, and periodic VISO alignment.

The ENU → NED transform

This is orientation-critical — every consumer of the vision pose depends on it, so a future refactor must reproduce it exactly (pose_bridge_with_covariance.py:186):
AxisENU (Isaac output)NED (sent to FC)
Position XEasty (North = ENU Y)
Position YNorthx (East = ENU X)
Position ZUp-z (Down = −ENU Z)
Rollroll_enuroll_enu (unchanged)
Pitchpitch_enu-pitch_enu (inverted)
Yawyaw_enu-yaw_enu + π/2
The yaw rule captures both the CCW→CW rotation-sense flip and the 90° reference-frame offset (ENU yaw 0 = East, NED yaw 0 = North). Getting this wrong doesn’t crash anything — it silently corrupts EKF heading fusion and the vehicle flies off in the wrong direction.

Adaptive covariance

Covariance is scaled up when the vehicle moves fast or tracking confidence is low, so the EKF trusts the vision pose less during aggressive manual movement and more when holding still (calculate_adaptive_covariance):
  • Movement-mode scale: STATIONARY ×1.0, SLOW_MANUAL ×2.0, FAST_MANUAL ×5.0, AUTONOMOUS ×1.5. Effective thresholds (declared ROS-param defaults, since SlamService passes neither): movement_threshold 0.1 m/s, fast_movement_threshold 1.0 m/s. The dataclass carries lower 0.05/0.5 fallbacks, but those are shadowed and never used at runtime.
  • Confidence scale: 4 − tracker_confidence (default confidence 3 → factor 1).
  • Base variances at launch are position_variance=0.5, orientation_variance=1.0 (passed by SlamService, overriding the node’s own 0.15/0.5 defaults). Results are clamped to pos [0.01, 1.0], ori [0.05, 2.0].
  • Anomaly guard: a position jump > 2.0 m or velocity > 5.0 m/s forces high covariance (pos 1.0, ori 2.0) for that frame.
The computed covariance matrix is not carried by the transmitted message. pose_callback sends the basic vision_position_estimate_send(...), which has no covariance field — the adaptive matrix is used for logging and mode tracking. A send_vision_position_delta path (which does support covariance) exists as a fallback but is not the active send. Preserve this distinction if you touch the send path.

EKF origin & VISO alignment caveats

Because there is no GPS, the EKF has no absolute reference — so the bridge fabricates one. Ten seconds after the first pose (and once first_pose_received), it sends SET_GPS_GLOBAL_ORIGIN followed by MAV_CMD_DO_SET_HOME using a hardcoded default origin:
pose_bridge_with_covariance.py:118
ekf_latitude:  -35.36596621649037
ekf_longitude: 149.17074503743248
ekf_altitude:  380.0   # meters
That default is ArduPilot’s Canberra SITL home — not the drone’s real location. Every GPS-denied flight initializes home/geofence relative to this fictitious origin unless you override ekf_latitude / ekf_longitude / ekf_altitude (ROS params, also hardcoded in slam_service.py:_launch_pose_bridge). Changing it moves home and shifts any geofence math. This is intentional for pure relative navigation but is a real gotcha for anything that mixes SLAM with map coordinates.
VISO alignment aligns the ArduPilot VISO heading to the SLAM frame by pulsing RC channel 7 high (1900) then releasing it — which requires RC7_OPTION=80 set in the ArduPilot parameters. It fires immediately on the first pose, again after viso_align_delay (2 s), and periodically every 30 s while the vehicle is stationary.

The serial-port caveat

The bridge opens a direct pyMAVLink serial connection — it does not go through mavp2p. It walks a candidate list at 921600 baud and connects to the first that answers a heartbeat:
pose_bridge_with_covariance.py:132
candidate_ports = ["/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyUSB0"]
baudrate = 921600
The default primary port /dev/ttyACM0 is also owned by mavp2p at 115200 baud — two processes cannot both hold the same serial device. On real hardware the VIO link therefore lands on a second FC serial port (e.g. ttyACM1/ttyUSB0). Before relying on the /dev/ttyACM0@921600 default, verify the actual physical wiring; this is a known documentation/behavior trap. The VIO serial path must stay distinct from the mavp2p MAVLink hub.

nvblox mapping: mapping vs patrol

nvblox builds the 3D occupancy map. SlamService picks its config from MAPPING_ENABLED — the launch mode is always static (static occupancy), only the params file changes (slam_service.py:400):

Mapping mode

MAPPING_ENABLED=true/home/configs/nvblox_mapping.yml. Builds and saves new high-quality maps. Map loading is skipped in this mode.

Patrol mode

MAPPING_ENABLED=false/home/configs/nvblox_patrol.yml. Loads a pre-built map for obstacle avoidance during autonomous runs.

Loading and saving maps

In patrol mode, if NVBLOX_MAP_PATH is set and the file exists, SlamService._load_map() calls the nvblox service after startup:
# Load a saved map (what _load_map runs internally)
ros2 service call /nvblox_node/load_map nvblox_msgs/srv/FilePath \
  "{file_path: '/home/maps/map.nvblx'}"

# Save the current map (mapping mode, run once the environment is fully explored)
ros2 service call /nvblox_node/save_map nvblox_msgs/srv/FilePath \
  "{file_path: '/home/maps/office_static_$(date +%Y%m%d_%H%M%S).nvblx'}"
A successful load publishes {ip}:map_loaded on Redis. There is also a nvblox_map_loader node exposing nvblox_map_loader/save_map for programmatic saves.

Obstacle avoidance (opt-in)

When OBSTACLE_AVOIDANCE=true, the pose bridge imports nvblox_obstacle_bridge and applies add_obstacle_functionality, a class decorator that grafts obstacle handling onto PoseBridgeWithCovariance at import time. The obstacle processor (nvblox_obstacle_bridge.py):
  • Subscribes to /nvblox_node/static_occupancy_grid (nav_msgs/OccupancyGrid).
  • Buckets occupied cells (occupancy ≥ obstacle_threshold 70) into 72 angular sectors (5° each), keeping the nearest obstacle per sector, with exponential smoothing and a 3-reading minimum before reporting.
  • Sends OBSTACLE_DISTANCE MAVLink at 10 Hz, distances in cm, min 0.5 m / max 20 m, in the MAV_FRAME_BODY_FRD (frame 12) body frame, so ArduPilot’s proximity/avoidance layer can act on it.
Obstacle avoidance rides on the same MAVLink connection as the pose bridge (it reuses self.mavlink). It only activates if the import succeeds; otherwise the bridge logs “Running WITHOUT obstacle avoidance functionality” and continues with pose-only injection.

Service orchestration & health

main.py builds a ServiceContainer (dependency injection) and registers services from IsaacConfig toggles. SlamService.start() runs a strict sequence:
1

Init ROS + announce

Create the slam_service ROS node, subscribe to /visual_slam/status, /visual_slam/tracking/vo_pose, /odom; optionally TTS-announce “slam_starting”.
2

Launch nvblox

pkill -f nvblox_node, then subprocess.Popen the nvblox_examples_bringup realsense_example.launch.py with the mapping/patrol params file. Wait ~10 s and verify nvblox_node is in ros2 node list.
3

Load map

In patrol mode, call /nvblox_node/load_map if NVBLOX_MAP_PATH is set. Failure is non-fatal (continues without a pre-loaded map).
4

Launch pose bridge

subprocess.Popen pose_bridge_with_covariance.py with --ros-args params (serial port, baud, coordinate system, covariance, EKF origin, OBSTACLE_AVOIDANCE passed through the environment).
5

Health monitor

A background thread polls both subprocesses, tracks pose age against pose_timeout (10 s), publishes /diagnostics, and TTS-announces slam_tracking_lost / slam_tracking_good transitions.
Both nvblox and the pose bridge run as subprocess.Popen children, not ROS 2 lifecycle nodes — a crash of main.py orphans/relaunches them. ROS_DOMAIN_ID=1 is exported in each subprocess launch string; RMW_IMPLEMENTATION=rmw_fastrtps_cpp is exported only in the nvblox launch string (slam_service.py:437), while the pose-bridge launch string exports OBSTACLE_AVOIDANCE instead (slam_service.py:536-537). Navigation-mode changes are surfaced on Redis:
Redis channelPublisherMeaning
{ip}:slam_statusSlamServicestopped only (slam_service.py:241); start status goes to the separate slam:status channel as a get_status() dict
{ip}:navigation_modeSlamService{mode: SLAM|GPS, status: enabled}
{ip}:map_loadedSlamServicemap load success + path
{ip}:navigation_errorSlamServiceSLAM/bridge not ready
{ip}:mode_changeMavrosStateMonitorArduPilot flight-mode / armed transitions
The isaac-slam service namespaces these channels with the MY_IP env var (default localhost), whereas core/gamepad key their channels off the resolved WireGuard wg0 IP (or IP_OVERRIDE). If MY_IP isn’t set to the drone’s VPN IP, SLAM status won’t line up with the channels the backend subscribes to. See the Redis Message Bus.

Configuration reference

Sourced from .env.example (§ ISAAC SLAM SERVICE) and the Compose isaac-slam service:
Env varDefaultPurpose
SLAM_ENABLEDtrueRegister/start the SLAM service at all
MAPPING_ENABLEDfalseMapping (nvblox_mapping.yml, save) vs patrol (nvblox_patrol.yml, load)
NVBLOX_MODEstaticnvblox occupancy mode
NVBLOX_MAP_PATH/home/maps/map.nvblxMap to load in patrol mode (skipped if unset)
OBSTACLE_AVOIDANCEfalseEnable the OBSTACLE_DISTANCE bridge (decorator)
FRONT_CAMERA_SERIAL<aws-account-id>RealSense serial for SLAM stereo
BACK_CAMERA_SERIAL<aws-account-id>Optional second camera (enables dual-camera mode)
DOCKING_CAMERA_SERIAL“ (empty)RealSense for rover docking (separate feature)
ROS_DOMAIN_ID1DDS domain shared with core/MAVROS (load-bearing)
SLAM_AUTO_START / SLAM_DEFAULT_MODEtrue / SLAMDeclared in .env.example; not consumed by the observed start path (SLAM starts whenever SLAM_ENABLED=true) — treat as advisory
NVIDIA_VISIBLE_DEVICES / NVIDIA_DRIVER_CAPABILITIESall / allGPU exposure (runtime: nvidia)
OPENAI_API_KEYTTS voice announcements (audio service)
Pose-bridge tuning parameters (serial_port, baudrate, send_rate_hz, position_variance, orientation_variance, ekf_latitude/longitude/altitude, VISO/covariance knobs) are passed as ROS 2 params by SlamService._launch_pose_bridge, not as top-level env vars — change them there or via --ros-args -p.
DETECTION_ENABLED and MISSION_ENABLED are read into service_configs but their services are not instantiated in main.py._init_services() — only slam, tts, audio (+ mavros_monitor, audio_listener), and docking start there. YOLO detection runs as its own separate entrypoint. Don’t assume this service starts detection or mission logic.

Running it

# Enable the slam profile (adds GPU service to the default set)
COMPOSE_PROFILES=mavproxy,core,rtk,gamepad,slam docker compose up -d isaac-slam

# Watch the pipeline come up
docker compose logs -f isaac-slam
./check-slam.sh                     # repo helper: SLAM status

# Manually run the bridge against a connected FC (debugging)
python3 pose_bridge_with_covariance.py --ros-args \
  -p serial_port:=/dev/ttyACM0 -p baudrate:=921600 \
  -p send_rate_hz:=30 -p dynamic_covariance:=true -p auto_viso_align:=true
No Jetson or RealSense? SLAM is GPU/hardware-bound and does not run under the SITL overlay. Use Local Development with SITL for GPS-based flows and reserve isaac-slam for on-hardware testing.

Microservices & Container Profiles

Where isaac-slam sits in the Compose topology, GPU/audio device wiring, and boot ordering.

MAVLink Routing (mavp2p)

Why SLAM bypasses the UDP hub and injects vision pose over a direct serial link.

Detection, ArUco Landing & Docking

The RealSense-D435 ArUco rover docking state machine that also lives in isaac-slam.

RTK NTRIP GPS Corrections

The complementary path: cm-level GPS when satellites are available.

Redis Message Bus

The {ip}: channel conventions and the MY_IP vs wg0 namespacing gotcha.

Guided Velocity Control & Safety

How GUIDED-mode setpoints ride on top of a SLAM-fed EKF.