Odometry on a Quadruped
Before anything: three things that will bite you
- The IMU may not be a standard topic. Unitree robots publish the body IMU inside
/lowstate. VIO packages expectsensor_msgs/Imu, so you need a small bridge node. - Do not borrow IMU noise parameters. The values shipped with a camera config describe that camera's IMU, not your robot's. Run an Allan variance test, or start from generic MEMS values.
- Calibrate the extrinsics yourself. If you mounted the camera, the camera–IMU transform is unique to your robot. See Camera–IMU Calibration with Kalibr.
1. Bridge the IMU to sensor_msgs/Imu
A small ROS2 node that subscribes to the robot's state message (/lowstate on Unitree) and republishes on /imu:
- Copy orientation, angular velocity and linear acceleration (on Unitree:
imu_state.quaternion,imu_state.gyroscope,imu_state.accelerometer). - If the source message has no ROS header, stamp the outgoing message with
get_clock().now()as soon as it arrives. - Check units: gyro in rad/s, accelerometer in m/s².
- Check the rate with
ros2 topic hz /imu. Robot IMUs often run at 500 or 1000 Hz, not the 200 or 400 Hz most templates assume.
When replaying a bag, run the bridge with use_sim_time:=true and play the bag with --clock.
2. Calibration
- Camera intrinsics: factory values from the camera's
camera_infotopic work;kalibr_calibrate_camerasis better. - IMU noise: record 2–4 hours of
/imuwith the robot perfectly still and runallan_variance_ros. - Camera–IMU extrinsics:
kalibr_calibrate_imu_camera. A ruler measurement is a last resort; monocular VIO will likely get the scale wrong.
If you have to start before the Allan variance run, these generic MEMS values are a rough placeholder:
accelerometer_noise_density: 2.0e-3
accelerometer_random_walk: 3.0e-3
gyroscope_noise_density: 1.7e-4
gyroscope_random_walk: 2.0e-5
3. OpenVINS
Build
mkdir -p ~/vins_ws/src && cd ~/vins_ws/src
git clone https://github.com/rpng/open_vins/
cd ~/vins_ws
colcon build --event-handlers console_cohesion+ \
--packages-select ov_core ov_init ov_msckf ov_eval
Sanity check on EuRoC
source ~/vins_ws/install/setup.bash
ros2 launch ov_msckf subscribe.launch.py config:=euroc_mav rviz_enable:=true
ros2 bag play ~/vins_ws/data/V1_01_easy
Configure for your robot
Copy the closest bundled config to a new folder (for a RealSense, rs_d455/ rather than euroc_mav/).
estimator_config.yaml:
use_stereo: false # monocular
max_cameras: 1
track_frequency: 30.0 # your actual camera rate
relative_config_imu: "my_robot_imu.yaml"
relative_config_imucam: "my_robot_extrinsics.yaml"
my_robot_imu.yaml (from kalibr_imu_chain.yaml): set rostopic: /imu, the measured update_rate, and the noise values from the Allan variance run.
my_robot_extrinsics.yaml (from kalibr_imucam_chain.yaml): replace T_cam_imu with the Kalibr result and set intrinsics, resolution and the image rostopic.
Run on a recording
# terminal 1
source ~/vins_ws/install/setup.bash
ros2 launch ov_msckf subscribe.launch.py config:=<your_config> rviz_enable:=true
# terminal 2: IMU bridge
ros2 run <your_package> <imu_bridge_node> --ros-args -p use_sim_time:=true
# terminal 3
ros2 bag play <bag> --clock
To open RViz separately:
ros2 run rviz2 rviz2 -d $(ros2 pkg prefix ov_msckf)/share/ov_msckf/launch/display_ros2.rviz \
--ros-args --log-level warn
Useful docs: installing, tutorial, datasets, calibration.
4. VINS-Fusion (ROS2 port)
Build
cd ~/vins_ws/src
git clone https://github.com/zinuok/VINS-Fusion-ROS2
cd ~/vins_ws
colcon build --packages-up-to vins --symlink-install
source ./install/setup.bash
On a machine without a CUDA build of OpenCV and Ceres, the port needs four changes:
vins/src/featureTracker/feature_tracker.h: comment out#define GPU_MODE 1.vins/src/utility/visualization.cpp:rclcpp::Duration(0)→rclcpp::Duration(0, 0)for Humble.vins/src/estimator/estimator.cpp: drop the CUDA dense linear algebra option and useoptions.linear_solver_type = ceres::DENSE_SCHUR;vins/src/estimator/parameters.cpp: initialiseUSE_GPU,USE_GPU_ACC_FLOWandUSE_GPU_CERESto0.
Run
ros2 run vins vins_node ~/vins_ws/src/VINS-Fusion-ROS2/config/<your_config>/mono_imu_config.yaml
rviz2 -d ~/vins_ws/src/VINS-Fusion-ROS2/config/vins_rviz_config.rviz
# plus the IMU bridge and `ros2 bag play <bag> --clock` as above
Sanity-check first with config/euroc/euroc_stereo_imu_config.yaml on a EuRoC sequence.
5. Invariant EKF (leg + IMU)
legged_state_estimator fuses IMU and leg kinematics with contact estimation. It ships with Unitree A1 settings, which work for other quadrupeds given the robot's URDF.
Install
conda create -n legged_est python=3.8 -y
conda activate legged_est
conda install -c conda-forge pinocchio eigen pybind11 cmake pkg-config compilers python=3.8 -y
pip install pybullet scipy matplotlib numpy pandas
git clone --recursive https://github.com/mayataka/legged_state_estimator.git
cd legged_state_estimator
# CMakeLists.txt is missing the Python3 requirement
sed -i '/find_package(pinocchio REQUIRED)/a find_package(Python3 COMPONENTS Interpreter Development REQUIRED)' CMakeLists.txt
grep "find_package(Python3" CMakeLists.txt # should print the added line
mkdir build && cd build
cmake .. -DPython3_EXECUTABLE=$(which python) -DCMAKE_BUILD_TYPE=Release
make -j4
Test with the A1 example
cd ../examples_python
export PYTHONPATH=$PYTHONPATH:$(pwd)/../build/bindings/python
cp ../bindings/python/legged_state_estimator/__init__.py ../build/bindings/python/legged_state_estimator/
python a1.py
How contact uncertainty is set
The contact covariance grows with how fast the normal ground reaction force changes:
const double df = normal_contact_force_estimate_[i] - normal_contact_force_estimate_prev_[i];
contact_force_covariance_[i] = settings_.contact_force_covariance_alpha * df * df;
That is, covariance = α · (Δfnormal)², with α typically 10 or 100. Large jumps in force are treated as unreliable contact.
Evaluating against GPS
The script below runs the estimator over a folder of recorded sequences and reports the absolute
trajectory error (ATE) of each against ground truth (GPS/RTK, motion capture, anything with a pose).
Export your recordings to the two CSV files described at the top of the script, set the paths, and run
it from examples_python/.
Show script
"""Run legged_state_estimator (InEKF) on recorded sequences and report ATE against ground truth.
Expected layout: one folder per sequence under DATA_ROOT, each containing
proprio.csv time [s], gyro_x/y/z [rad/s], accel_x/y/z [m/s^2],
and for each motor i in 0..11: motor_{i}_q, motor_{i}_dq, motor_{i}_tau
(Unitree motor order: FR, FL, RR, RL; hip, thigh, calf)
groundtruth.csv time [s], x, y, z [m], yaw [rad] (any world frame, e.g. GPS/RTK or mocap)
Both trajectories are moved to the origin and rotated so the initial heading is +X
before computing the error, so only drift is measured.
"""
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import legged_state_estimator
from scipy.interpolate import interp1d
# --- Configuration ---
URDF_PATH = "path/to/robot.urdf"
DATA_ROOT = "path/to/sequences"
OUTPUT_ROOT = "./odometry_results"
TIME_STEP = 0.002 # IMU / joint rate, here 500 Hz
IMU_FRAME = "imu"
CONTACT_FRAMES = ["FL_foot", "FR_foot", "RL_foot", "RR_foot"]
LPF_CUTOFF = 250 # IMU low-pass [Hz]: higher = more jitter, less lag
JOINT_CUTOFF = 10 # joint low-pass [Hz], A1 default
# CSV leg order (FR, FL, RR, RL) -> URDF leg order (FL, FR, RL, RR): first motor index per URDF leg
LEG_MAP = [3, 0, 9, 6]
def align_to_start(t, xyz, yaw, t_start, t_end):
"""Crop to [t_start, t_end], move the first pose to the origin and rotate it to face +X."""
mask = (t >= t_start) & (t <= t_end)
if np.sum(mask) < 2:
return None, None
t, xyz, yaw = t[mask], xyz[mask], yaw[mask]
c, s = np.cos(-yaw[0]), np.sin(-yaw[0])
R = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
return t, (R @ (xyz - xyz[0]).T).T
def run_estimator(proprio_path):
"""Return a DataFrame with time, x, y, z. The estimator starts at the origin facing +X."""
df = pd.read_csv(proprio_path)
settings = legged_state_estimator.LeggedStateEstimatorSettings.UnitreeA1(URDF_PATH, TIME_STEP)
settings.lpf_gyro_accel_cutoff_frequency = LPF_CUTOFF
settings.lpf_lin_accel_cutoff_frequency = LPF_CUTOFF
settings.lpf_dqJ_cutoff_frequency = JOINT_CUTOFF
settings.lpf_tauJ_cutoff_frequency = JOINT_CUTOFF
settings.imu_frame = IMU_FRAME
settings.contact_frames = CONTACT_FRAMES
estimator = legged_state_estimator.LeggedStateEstimator(settings)
estimator.init(
base_pos=np.array([0.0, 0.0, 0.28]), # nominal standing height
base_quat=np.array([0.0, 0.0, 0.0, 1.0]),
base_lin_vel_world=np.zeros(3),
imu_gyro_bias=np.zeros(3),
imu_lin_accel_bias=np.zeros(3),
)
out = []
for _, row in df.iterrows():
gyro = row[['gyro_x', 'gyro_y', 'gyro_z']].to_numpy(dtype=float)
accel = row[['accel_x', 'accel_y', 'accel_z']].to_numpy(dtype=float)
qJ, dqJ, tauJ = np.zeros(12), np.zeros(12), np.zeros(12)
for leg, first in enumerate(LEG_MAP):
for j in range(3):
m = first + j
qJ[3*leg + j] = row[f'motor_{m}_q']
dqJ[3*leg + j] = row[f'motor_{m}_dq']
tauJ[3*leg + j] = row[f'motor_{m}_tau']
estimator.update(gyro, accel, qJ, dqJ, tauJ)
out.append((row['time'], *estimator.base_position_estimate))
return pd.DataFrame(out, columns=['time', 'x', 'y', 'z'])
def main():
os.makedirs(OUTPUT_ROOT, exist_ok=True)
results = []
for seq_dir in sorted(f.path for f in os.scandir(DATA_ROOT) if f.is_dir()):
seq = os.path.basename(seq_dir)
proprio = os.path.join(seq_dir, "proprio.csv")
gt_file = os.path.join(seq_dir, "groundtruth.csv")
if not (os.path.exists(proprio) and os.path.exists(gt_file)):
print(f"[skip] {seq}: missing files")
continue
est = run_estimator(proprio)
gt = pd.read_csv(gt_file)
t_start = max(est['time'].min(), gt['time'].min())
t_end = min(est['time'].max(), gt['time'].max())
if t_end - t_start < 1.0:
print(f"[skip] {seq}: less than 1 s overlap")
continue
gt_t, gt_xyz = align_to_start(gt['time'].to_numpy(), gt[['x', 'y', 'z']].to_numpy(),
gt['yaw'].to_numpy(), t_start, t_end)
# Sample the estimate at ground-truth timestamps and zero it at the window start
est_xyz = interp1d(est['time'], est[['x', 'y', 'z']], axis=0,
fill_value="extrapolate")(gt_t)
est_xyz -= est_xyz[0]
ate = np.sqrt(np.mean(np.sum((est_xyz - gt_xyz) ** 2, axis=1)))
results.append({"sequence": seq, "ate_rmse": ate, "samples": len(gt_t)})
print(f"{seq}: ATE {ate:.3f} m")
plt.figure(figsize=(8, 7))
plt.plot(gt_xyz[:, 0], gt_xyz[:, 1], 'k--', label='Ground truth')
plt.plot(est_xyz[:, 0], est_xyz[:, 1], 'r-', label='InEKF')
plt.title(f"{seq} ATE {ate:.3f} m")
plt.xlabel("x [m]")
plt.ylabel("y [m]")
plt.axis('equal')
plt.legend()
plt.grid(alpha=0.3)
plt.savefig(os.path.join(OUTPUT_ROOT, f"{seq}.png"))
plt.close()
if results:
df = pd.DataFrame(results)
df.to_csv(os.path.join(OUTPUT_ROOT, "ate.csv"), index=False)
print(f"\nMean ATE over {len(df)} sequences: {df['ate_rmse'].mean():.3f} m")
if __name__ == "__main__":
main()