Localization (control/localization)¶
Role¶
External-navigation integration for GPS-denied (indoor) flight. Feeds a Visual SLAM pose to the FCU so its EKF (ArduPilot EKF3 / PX4 EKF2) can estimate position without GPS.
It lives under control because it bridges the VSLAM producer to the MAVROS and
MAVLink transports. The pipeline is split into a producer (RealSense + Isaac
ROS Visual SLAM, runs in the Isaac container) and a consumer (a vision-pose
bridge that forwards the pose to the FCU, runs on the SDK side). They communicate
over host networking with a shared ROS_DOMAIN_ID.
Topology¶
flowchart LR
subgraph isaac [Isaac container]
rs[realsense2_camera infra1/2 + IMU]
vslam[isaac_ros_visual_slam]
rs --> vslam
end
subgraph sdk [SDK container or host]
bridge[vision_pose_node backend mavros/mavlink]
mavros[MAVROS indoor]
end
vslam -->|"/visual_slam/tracking/vo_pose_covariance"| bridge
bridge -->|"mavros: /mavros/vision_pose/pose_cov"| mavros
bridge -->|"mavlink: VISION_POSITION_ESTIMATE"| fcu[FCU]
mavros --> fcu
Components¶
| Part | Path | Role |
|---|---|---|
| Producer launch | nectar/launch/isaac_vslam_realsense.launch.py |
RealSense + Visual SLAM (Isaac container) |
| Consumer launch | nectar/launch/vision_pose.launch.py |
MAVROS (optional) + vision-pose bridge |
| MAVROS relay | control/localization/vision_pose_bridge.py (MavrosVisionRelay) |
republish pose to /mavros/vision_pose/pose_cov |
| MAVLink bridge | reused from nectar.control.mavlink.VisionPoseBridge |
send VISION_POSITION_ESTIMATE |
| DDS bridge | control/px4/vision_bridge.py (Px4VisionOdometryBridge) |
publish px4_msgs/VehicleOdometry to /fmu/in/vehicle_visual_odometry |
| Node | control/localization/nodes/vision_pose_node.py |
select backend, wire the bridge |
| VSLAM params | control/localization/config/vslam_realsense.yaml |
RealSense + Visual SLAM tuning |
| MAVROS config | control/mavros/config/indoor_mavros.yaml, indoor_pluginlists.yaml |
indoor MAVROS profile |
Run¶
Prerequisite: both sides must share
ROS_DOMAIN_ID(default14, seescripts/lib/config.sh).
- Producer (Isaac container). One self-contained command clones
isaac_ros_common(release-3.2), pulls the prebuilt NVCR base, builds the Nectar layer, and drops you into the Isaac container with the workspace mounted:
make isaac-run # or: ./docker/isaac_vslam/run_docker.sh
# inside the container, start the producer with the baked helper:
nectar-vslam # = ros2 launch nectar/launch/isaac_vslam_realsense.launch.py
- Consumer (SDK container or host), pick the transport you fly with:
MAVROS transport:
Direct pymavlink transport (no MAVROS):
Native uXRCE-DDS (PX4 only; needs a running MicroXRCEAgent + px4_msgs):
FCU setup¶
The bridge only delivers the pose — the FCU's estimator still has to be told to
fuse it, and ArduPilot (EKF3) and PX4 (EKF2) use different parameters and a
different minimum rate. Both backends send MAVLink
VISION_POSITION_ESTIMATE
(#102); the EKF fuses it once configured. With no GPS, the EKF origin must be
set before position control engages.
Our indoor flights to date are on ArduPilot 4.6.x / 4.8-dev. The PX4 set below is for our planned move to PX4 on Pixhawk: it is grounded in the PX4 docs and matches the reference pipeline (the same MAVROS relay) used by the VSLAM-UAV tutorial on PX4 v1.15.4, but we have not yet flown it on our own hardware.
ArduPilot (EKF3)¶
| Parameter | Value | Purpose |
|---|---|---|
VISO_TYPE |
1 (MAVLink) |
Enable the external-nav backend that consumes VISION_POSITION_ESTIMATE from a companion (the T265 path uses 2, see Hardware notes) |
EK3_SRC1_POSXY |
6 (ExternalNav) |
Horizontal position from VSLAM |
EK3_SRC1_VELXY |
6 or 0 |
Horizontal velocity (cuVSLAM provides it) or none |
EK3_SRC1_POSZ |
6 (ExternalNav) |
Height — see Height source |
EK3_SRC1_VELZ |
6 or 0 |
Vertical velocity or none |
EK3_SRC1_YAW |
6 (ExternalNav) |
Yaw from VSLAM (with COMPASS_USE=0), or 1 to keep the compass |
VISO_POS_X/Y/Z |
camera offset (m) | Camera position in the body frame |
GPS1_TYPE |
0 |
Disable GPS indoors (renamed from GPS_TYPE in 4.5+) |
Rate ≥ 4 Hz. Tuning: VISO_POS_M_NSE, VISO_YAW_M_NSE, VISO_DELAY_MS,
VISO_QUAL_MIN. Set the origin via Mission Planner ("Set EKF Origin"),
the SET_GPS_GLOBAL_ORIGIN message, or the ahrs-set-origin.lua script.
Refs: EKF source selection,
Non-GPS position estimation,
VISO_TYPE
and EK3_SRC*
source.
PX4 (EKF2)¶
| Parameter | Value | Purpose |
|---|---|---|
EKF2_EV_CTRL |
bitmask — bit0 h-pos, bit1 v-pos, bit2 3D vel, bit3 yaw (15 = all) |
Enable external-vision fusion. Released PX4 (≥1.14) has shipped this at 15, so fusion auto-starts when data arrives — verify on your firmware |
EKF2_HGT_REF |
3 (Vision) |
Height reference — see Height source |
EKF2_EV_DELAY |
~50 ms |
EV delay relative to IMU; tune from logs |
EKF2_EV_POS_X/Y/Z |
camera offset (m) | Camera position in the body frame |
EKF2_GPS_CTRL |
0 |
Disable GNSS indoors |
EKF2_MAG_TYPE |
None |
Only if using vision yaw |
Rate 30–50 Hz — PX4 rejects external vision when the rate is too low (much
stricter than ArduPilot); cuVSLAM at ~90 Hz clears this. Tuning:
EKF2_EV_NOISE_MD, EKF2_EVP_NOISE, EKF2_EVA_NOISE, EKF2_EV_QMIN. Auto and
position modes from a purely local estimate need SET_GPS_GLOBAL_ORIGIN.
Refs: External position estimation,
VIO,
EKF2 tuning,
params_external_vision.yaml,
EKF2_EV_CTRL default (#24298).
The mavros/mavlink backends deliver this estimate as VISION_POSITION_ESTIMATE;
the native dds backend publishes px4_msgs/VehicleOdometry on
/fmu/in/vehicle_visual_odometry instead (same EKF2 params, no MAVROS/MAVLink).
See Backends.
Height source¶
We do not use the barometer indoors — it drifts near the ground and in prop wash. We pick POSZ between two sources and keep the rest of the set (POSXY / VELXY / YAW) on vision:
- Vision (
EK3_SRC1_POSZ=6/EKF2_HGT_REF=Vision): altitude is relative to the VSLAM origin, no terrain following — the drone holds a constant height when crossing platforms or obstacles in the arena. This is our default and has been useful for missions with raised platforms. - Downward rangefinder (
EK3_SRC1_POSZ=2/EKF2_HGT_REF=Range+EKF2_RNG_CTRL): altitude follows the terrain. ArduPilot warns this is "only appropriate ... where the floor is flat with no ground clutter" (EKF sources); PX4 notes "the local NED origin will move up and down with ground level" (EKF2 tuning). Over fixed obstacles the vehicle then climbs to compensate, so we mask the step drops upstream with theObstacleMaskFilterbefore the FCU ever sees them.
Backends¶
mavros:MavrosVisionRelayrepublishes the VSLAMPoseWithCovarianceStamped(ENU) onto/mavros/vision_pose/pose_cov; MAVROS converts to NED for the FCU.mavlink:nectar.control.mavlink.VisionPoseBridgeconverts ENU->NED and sendsVISION_POSITION_ESTIMATEover a dedicated pymavlink link.dds:nectar.control.px4.Px4VisionOdometryBridgeconverts ENU->NED and publishespx4_msgs/VehicleOdometryon/fmu/in/vehicle_visual_odometry(PX4 native uXRCE-DDS). Needs a runningMicroXRCEAgentandpx4_msgs; setpx4_namespaceto match a namespaced client.
Visualization¶
Pre-flight check from the laptop (same ROS_DOMAIN_ID as the Jetson): move the
drone by hand and confirm the pose tracks, is low-noise, and that the path snaps
back on return (loop closure). NVIDIA recommends running RViz on a remote PC, not
the Jetson, to avoid loading the VSLAM node
(RealSense tutorial).
Two profiles (rviz/vslam_light.rviz, rviz/vslam_full.rviz):
| Profile | Shows | Producer cost |
|---|---|---|
light (default) |
TF + odometry + SLAM path (green) + VO path (purple) | none (tracking topics are always published) |
full |
light + landmarks / loop-closure clouds + pose graph | requires enable_visualization:=true |
The light profile draws two always-published trajectories: the green SLAM
path (/visual_slam/tracking/slam_path, loop-closure-corrected) and the
purple VO path (/visual_slam/tracking/vo_path, raw odometry). When a loop
closes, the green path snaps relative to the purple — that is the loop closure,
visible with no producer cost. The literal purple loop-closure point cloud lives
in full (it is a /visual_slam/vis/* topic, only published with
enable_visualization:=true).
Both light paths are shown as a rolling buffer (default last 15 s) so the
window does not fill with the whole trajectory. vslam_rviz.launch.py runs a
path_window_node relay next to RViz (no Jetson cost) that republishes
/visual_slam/tracking/{slam,vo}_path trimmed to the last window_seconds onto
*_windowed topics, which the light profile subscribes to. Set window_seconds
to 0 for full history.
Laptop (nectar built):
ros2 launch nectar vslam_rviz.launch.py profile:=light # or profile:=full
ros2 launch nectar vslam_rviz.launch.py window_seconds:=30 # longer buffer
Laptop (repo only, no build) — run the relay too, else the light paths are empty:
ros2 run nectar path_window_node.py # if built; otherwise: python3 .../nodes/path_window_node.py
rviz2 -d src/nectar-sdk/nectar/nectar/control/localization/rviz/vslam_light.rviz
The full profile needs the producer to publish the /visual_slam/vis/* topics,
which are off by default to keep the Jetson light:
Hardware notes¶
Current rig: Intel RealSense D435i + Isaac ROS Visual SLAM (cuVSLAM) on a
Jetson Orin; pose output ~90 Hz. This is the producer the launch files target
(make isaac-run to build/enter the Isaac container, then nectar-vslam to
start the camera and SLAM; the bridge runs in a second terminal).
Legacy / fallback: Intel RealSense T265 +
vision_to_mavros (our
ROS 2 port of thien94/vision_to_mavros),
which aligns the T265 /tf to ENU and publishes /mavros/vision_pose/pose
(VISO_TYPE=2). Black Bee flew the T265 in 2023 (3rd place indoor at IMAV 2023,
our first indoor drone). We moved to the D435i + cuVSLAM after 2024: the T265 is
discontinued and needs legacy librealsense/realsense-ros, is sensitive to
vibration and to the environment, and a crash that cracked its lens cover left it
tracking less reliably. The T265 path still works and remains a fallback. The
companion-side setup we started from is LuckyBird's T265 series
(part 1)
and the ArduPilot ROS VIO
and Intel T265 pages.