Skip to main content
The Innex1 Rover uses a dual Extended Kalman Filter (EKF) architecture with AprilTag visual landmarks for robust localization in GPS-denied lunar environments.

Localization Architecture

The system employs two independent EKF instances:
  1. Local EKF (odombase_footprint) - Smooth, continuous odometry without jumps
  2. Global EKF (mapodom) - Corrects drift using AprilTag detections
This dual-filter approach prevents discontinuities in the local odometry frame while allowing global pose correction.

AprilTag Pose Publisher

Location: src/lunabot_localisation/lunabot_localisation/tag_pose_publisher.py

Transform Chain

The TagPosePublisher node computes the robot’s global pose by chaining transforms:
map → tag (static) × camera → tag (detected) × camera → base (URDF)
1

Lookup static tag location

map_to_tag = self.tf_buffer.lookup_transform(
    self.map_frame,      # "map"
    self.tag_frame,      # "tag36h11:0"
    Time()
)
2

Get AprilTag detection

camera_to_detected_tag = self.tf_buffer.lookup_transform(
    self.detected_tag_frame,  # "tag36h11:0_detection"
    self.camera_frame,        # "camera_front_optical_frame"
    Time()
)
3

Transform to base_footprint

base_to_camera = self.tf_buffer.lookup_transform(
    self.camera_frame,
    self.target_frame,   # "base_footprint"
    Time()
)
4

Compose full transform

pose_in_camera = tf2_geometry_msgs.do_transform_pose(
    base_origin, base_to_camera
)
pose_in_detected_tag = tf2_geometry_msgs.do_transform_pose(
    pose_in_camera, camera_to_detected_tag
)
pose_in_map = tf2_geometry_msgs.do_transform_pose(
    pose_in_detected_tag, detected_tag_to_map
)

Distance-Based Covariance

Measurement uncertainty increases with tag distance:
dx = camera_to_detected_tag.transform.translation.x
dy = camera_to_detected_tag.transform.translation.y
dz = camera_to_detected_tag.transform.translation.z
distance = math.sqrt(dx * dx + dy * dy + dz * dz)

# Accuracy degrades with range
cov_xy = max((distance * 0.1) ** 2, 0.01)
cov_yaw = (distance * 0.05) ** 2 + 0.01
Covariance scaling:
  • XY position: 10% of distance, minimum 10 cm
  • Yaw: 5% of distance, minimum 0.1 rad

Published Message

msg = PoseWithCovarianceStamped()
msg.header.frame_id = "map"
msg.pose.pose = pose_in_map

# 6x6 covariance matrix (only diagonal populated)
cov[0] = cov_xy       # X variance
cov[7] = cov_xy       # Y variance
cov[14] = 1e6         # Z (ignored in 2D mode)
cov[21] = 1e6         # Roll (ignored)
cov[28] = 1e6         # Pitch (ignored)
cov[35] = cov_yaw     # Yaw variance
Published to /tag_pose at 10 Hz when tag is visible.
Z, roll, and pitch covariances are set to 1e6 to signal these DOFs are not observed in 2D planar localization.

Dual EKF Configuration

Location: src/lunabot_localisation/config/ekf.yaml

Local EKF (Odometry Frame)

ekf_filter_node_odom:
  ros__parameters:
    frequency: 50.0
    two_d_mode: true
    publish_tf: true
    
    world_frame: odom
    odom_frame: odom
    base_link_frame: base_footprint
Input sources:
odom0: /odom
odom0_config:
  # [X, Y, Z, Roll, Pitch, Yaw, VX, VY, VZ, VRoll, VPitch, VYaw, AX, AY, AZ]
  [true,  true,  false, false, false, false,
   true,  false, false, false, false, true,
   false, false, false]

Global EKF (Map Frame)

ekf_filter_node_map:
  ros__parameters:
    frequency: 50.0
    two_d_mode: true
    publish_tf: true
    
    world_frame: map
    odom_frame: odom
    base_link_frame: base_footprint
Additional AprilTag input:
pose0: /tag_pose
pose0_config:
  [true,  true,  false, false, false, true,   # Use X, Y, Yaw
   false, false, false, false, false, false,
   false, false, false]
pose0_differential: false
pose0_relative: false
pose0_rejection_threshold: 5.0
pose0_differential: false is critical - AprilTag measurements provide absolute pose in the map frame, not relative motion.

State Vector Configuration

The 15-element state vector:
IndexVariableLocal EKFGlobal EKF
0-2X, Y, ZX, Y fusedX, Y fused
3-5Roll, Pitch, YawYaw fusedYaw fused
6-8VX, VY, VZVX, Yaw velVX, Yaw vel
9-11Roll vel, Pitch vel, Yaw velYaw velYaw vel
12-14AX, AY, AZAXAX

Parameter Configuration

Tag Pose Publisher Parameters

tag_pose_publisher:
  ros__parameters:
    tag_frame: "tag36h11:0"                    # Static tag in world
    detected_tag_frame: "tag36h11:0_detection" # Live detection frame
    camera_frame: "camera_front_optical_frame"
    target_frame: "base_footprint"
    map_frame: "map"

Rejection Threshold

pose0_rejection_threshold: 5.0
AprilTag measurements are rejected if they differ from the predicted state by more than 5.0 standard deviations (Mahalanobis distance).
This prevents spurious detections or tag misidentifications from corrupting the global pose estimate.

Frame Tree

map (global, fixed)
 ├─ tag36h11:0 (static beacon)
 └─ odom (drifts over time)
     └─ base_footprint (rover base)
         └─ camera_front_optical_frame
             └─ tag36h11:0_detection (when visible)
Published by:
  • mapodom: Global EKF
  • odombase_footprint: Local EKF
  • base_footprintcamera_front_optical_frame: robot_state_publisher (URDF)
  • camera_front_optical_frametag36h11:0_detection: AprilTag detector

Running Localization

1

Launch both EKF nodes

ros2 launch robot_localization dual_ekf.launch.py
2

Start AprilTag detector

ros2 launch apriltag_ros tag_36h11.launch.py
3

Run tag pose publisher

ros2 run lunabot_localisation tag_pose_publisher
4

Verify transforms

ros2 run tf2_ros tf2_echo map base_footprint

Monitoring Localization Quality

Check Tag Detection Rate

ros2 topic hz /tag_pose
Should show ~10 Hz when tag is visible.

Inspect Covariance

ros2 topic echo /tag_pose --field pose.covariance
Lower values indicate higher confidence measurements.

Visualize in RViz

# Add PoseWithCovarianceStamped display
topic: /tag_pose
color: green
shape: arrow
covariance: show XY
If the global EKF jumps erratically, check:
  1. pose0_rejection_threshold may be too high
  2. AprilTag detection quality (lighting, distance)
  3. Static transform maptag36h11:0 accuracy

Benefits of Dual EKF Architecture

AspectSingle EKFDual EKF
Local smoothnessJumps when global correction appliedAlways continuous
Global accuracyCorrects driftCorrects drift
Controller stabilityMay destabilize on jumpsStable - uses smooth odom frame
SLAM compatibilityDifficultNatural - global corrections in map↔odom
The local EKF provides the odombase_footprint transform used by controllers, ensuring smooth velocity commands even when AprilTag updates cause global position jumps.

Build docs developers (and LLMs) love