FusionCore, which is a ROS 2 Jazzy sensor fusion package (robot_localization replacement)

Hey everyone,
I’ve been working on FusionCore for the last few months… it’s a ROS 2 Jazzy sensor fusion package that addresses the feature gaps in robot_localization, which has seen reduced development since 2023 and lacks native ECEF GPS, IMU bias estimation, and adaptive noise covariance.

There wasn’t anything user-friendly available for ROS 2 Jazzy. It merges IMU, wheel encoders, and GPS/GNSS into a single, reliable position estimate at 100Hz. No need for manual covariance matrices… just one YAML config file.

  • It uses an Unscented Kalman Filter (UKF) with a complete 3D state… and it’s not just a port of robot_localization.
  • It features native GNSS fusion in ECEF coordinates, so you won’t run into UTM zone issues.
  • It supports dual antenna heading right out of the box…
  • It automatically estimates IMU gyroscope and accelerometer bias.
  • It includes HDOP/VDOP quality-aware noise scaling, which means bad GPS fixes are automatically down-weighted.
  • It’s under the Apache 2.0 license, making it commercially safe.
  • And it’s built natively for ROS 2 Jazzy… not just a port.

GitHub: https://github.com/manankharwar/fusioncore

I respond to issues within 24 hours. If you’re working on a wheeled robot with GPS on ROS 2 Jazzy and hit problems… open an issue or reply here.

7 Likes

Thanks for the package! I really like the simplicity and cleanliness of the design.

However, it seems to be too simple so far in several areas:

  • IMUs rarely come in base_link frame. You should use imu_transformer.
  • GNSS also rarely comes in base_link. This is, however, a much harder problem, because if the antenna is offset from the axis of the robot, its readings are for a point that follows different trajectory than base_link. This problem itself adds a few 100s lines to robot_localization, yet it is a very important one to resolve (and no, please, do not take inspiration from r_l which just saves the very first heading as an offset). Until you’re sure with the heading of the robot, fusing off-center GNSS is not good.
  • Why do you ignore covariances in the messages? Some sensors/algorithms actually put useful values there… Most importantly GNSS has often off-diagonal elements that are quite important (and DOPs are only very lightly related to the quality of localization). Or a wheel-slip estimating odometry.
  • But I agree if a sensor publishes bogus covariance, it is nice to have an easy way to override it.
  • Some IMUs provide very good orientation estimates. Why not use them?
  • What about using multiple sources of each type?

Out of which box? I don’t see anything heading-related in the ROS node (only code in the core, but no ROS interface).

If you’re also missing a message to represent the heading, please consider using compass/compass_msgs/msg/Azimuth.msg at master · ctu-vras/compass · GitHub . One day, I’d like to standardize it. The more users it has, the more feedback I have and the better the standard will be.

1 Like

Hello… thank you, this is exactly the feedback I needed. You are so wholesome.

Responding honestly to each point:
IMU frame transform: You’re absolutely right, this is missing and it’s the highest priority fix. The node currently assumes IMU data arrives in base_link frame which is almost never true. Adding proper TF-based frame transformation before fusion. (no matter how much innovation we think… it all boils down to experience… Kudos to you).

GNSS antenna offset: Acknowledged: this is a known hard problem I haven’t solved yet. FusionCore currently assumes the antenna is at base_link. On the roadmap but I want to do it properly. I think I have an understanding that will resolve this.

Ignoring message covariances: Fair. I’ll use message covariances when they’re non-zero and meaningful, and fall back to config params when they’re not. This is a straightforward fix.

IMU orientation estimates: Very good point… adding orientation as a direct measurement input, not just raw gyro/accel.

Dual antenna heading not in ROS node: You’re correct…. the core C++ has the math but it’s not wired to a ROS topic yet. Fixing this in the next release. I’ll look at compass_msgs… standardization makes sense.

FusionCore is early. The core math works. The ROS integration has real gaps your feedback made clear. Working through this list now.

Thanks for taking the time.

2 Likes

On the topic, I’m genuinely curious if there is a fully featured “standard” way to output dual antenna data in the first place. The compass message you mention works, but only captures a very small amount of the data these units typically output with heading. For instance a dual antenna setup also provides either a pitch or roll measurement, not just heading.

In the past I’ve made a custom message (which admittedly contains some redundant data): DifferentialMeasurement — marti_sensor_msgs 1.6.1 documentation

Theoretically you can represent it with a generic nav_msgs/Odometry message or with multiple fixes, but I haven’t found those line up well with the actual output of GPS devices I’ve used.

For instance you can see a typical GPS output here: https://content.u-blox.com/sites/default/files/documents/u-blox-F9-HPG-1.51_InterfaceDescription_UBXDOC-963802114-13124.pdf#[{“num”%3A2829%2C"gen"%3A0}%2C{“name”%3A"XYZ"}%2C59.527%2C129.637%2Cnull]

And here: NovatelDualAntennaHeading — novatel_gps_msgs: Rolling 4.2.1 documentation

2 Likes

Hi peci1!! Thank you again for the detailed review. I spent the last few days working through your list properly.

Here’s what’s been fixed:

IMU frame transform: the node now reads frame_id from the IMU message, looks up the TF rotation to base_link, and transforms angular velocity and linear acceleration before fusing. If the transform is missing, it prints the exact static_transform_publisher command to fix it and falls back gracefully.

GNSS antenna offset: implemented with a proper observability guard. The lever arm correction only activates when heading has been independently validated from a real source. Specifically, heading_validated_ is only set true when: a dual antenna heading message is received, an AHRS/IMU with magnetometer publishes orientation, or the robot has traveled >= 5 meters at sufficient speed with low yaw rate. Before any of these, lever arm is disabled regardless of what yaw variance says…. so the filter cannot fake its way into applying the correction.

Message covariances: GNSS now uses the full 3x3 covariance matrix when position_covariance_type == 3, including off-diagonal elements. Wheel odometry reads twist.covariance per-axis when available. Both fall back to config params when covariance is zero or unknown. Config params still work as overrides for sensors with bogus covariance.

IMU orientation: accepted as a direct measurement. Added imu.has_magnetometer config flag…. when false (default, 6-axis IMUs), orientation fuses roll/pitch but does not validate heading. When true (9-axis: BNO08x, VectorNav, Xsens), heading is validated. This prevents gyro drift from silently activating lever arm correction.

Multiple sources: second GNSS receiver configurable via gnss.fix2_topic. Multiple IMU sources via separate TF-aware callbacks.

Dual antenna heading: fully wired to a ROS topic (gnss.heading_topic, default /gnss/heading).

Uses sensor_msgs/Imu orientation quaternion: acknowledged this is slightly awkward but it’s what most u-blox and Septentrio drivers publish.

compass_msgs/Azimuth: added support. The upstream package is ROS 1 only so I ported the identical message definition to ROS 2 Jazzy natively. FusionCore now accepts compass_msgs/Azimuth on a configurable topic, handles ENU/NED convention conversion, RAD/DEG units, and warns when magnetic north reference is used instead of geographic.

Happy to contribute the ROS 2 port back upstream if that’s useful to you.

Do you mind testing it out and/or if you have any questions or if you spot any red flags then please let me know…

1 Like

Ah Great point… you’re right that compass_msgs/Azimuth only captures a subset of what dual antenna units actually output. Heading + variance is enough for wheeled robots with horizontally mounted antennas, but pitch, baseline length, and fix type are genuinely useful data that FusionCore currently doesn’t consume.

This is on my radar. The pitch measurement would fuse directly into the filter’s pitch state…. same pattern as heading into yaw…. and baseline validation would strengthen the fix quality check. The blocker is the message format question you raise: NovAtel has their format, u-blox has theirs, compass_msgs covers heading only. I’d rather wait for a bit more community consensus on what the standard should look like than hardcode one vendor’s format.

If you’re working with a specific receiver and want to test this…. open an issue and let’s figure out the right interface together. Happy to prioritize it if there’s real hardware to test against.

Congrats on releasing your package! It’s always great to give the community different options to meet their needs. Just a few notes, as your bullet list seems to imply some inaccuracies:

  • robot_localization does not require manual covariance matrices. It provides the option for the user to specify the state’s initial covariance, as well as the process noise covariance. The configuration can also be done in a single yaml file.
  • We merged this PR on robot_localization recently. It adds ECEF support.
  • robot_localization also accounts for the GPS-reported covariance.
  • The license for robot_localization is also Apache 2.0. It has been used commercially on tens of thousands of robots, so I would argue it is also commercially safe.

However, the rest of the feature set looks very cool! Well done.

2 Likes

Hey!! thanks for taking the time to go through this so carefully. You clearly know the robot_localization codebase inside out, and I would much rather have corrections like this out in the open than leave something inaccurate sitting in a pinned post. I will go through each point as honestly as I can.

On covariance matrices

You are right. I went back to the source and found this in gpsFixCallback:

for (size_t i = 0; i < POSITION_SIZE; i++) {
for (size_t j = 0; j < POSITION_SIZE; j++) {
latest_cartesian_covariance_(i, j) =
msg->position_covariance[POSITION_SIZE * i + j];
}
}

So, RL is reading the GPS reported covariance and rotating it into the world frame before the Kalman update. My original claim was just wrong. I have updated the README and docs to remove that.

On ECEF

I want to be a bit careful here because I think there is a real distinction.

I looked at the PR that got merged. It adds a broadcast_earth_transform parameter that publishes an earth to world TF frame from navsat_transform. That is genuinely useful for REP 105 compliance and I think it is a solid addition.

But it is not quite what I meant when I said FusionCore handles GPS in ECEF. In FusionCore, the UKF state itself runs in ECEF coordinates. The lat lon alt from NavSatFix gets converted to ECEF XYZ once, and then everything happens there. The innovation, the covariance update, the state correction, all of it. There is no UTM projection anywhere in the pipeline.

In RL navsat_transform, even with that PR, the GPS measurement still goes through GeographicLib UTMUPS Forward or gps_local_cartesian Forward before it ever reaches the filter. The earth frame gets published at the end, not used internally for fusion. If I am misunderstanding that part of the code I am very open to being corrected.

Also worth mentioning, that PR is only on rolling devel right now and has not been ported to Jazzy yet, which is where most of my users are.

On the NaN divergences

This is the part I am most interested in discussing.

The RL UKF results I saw in my benchmark were not a config issue. I used the default configuration on the same data and got NaN on all six sequences. I want to explain what I think might be going on, because it seems tied to a deeper architectural difference.

Looking at navsat_transform, I do not see any chi squared gating. RL takes the GPS covariance and feeds it into the Kalman update, which then weights the measurement. That is correct Kalman behavior. But even if something gets down weighted, it can still be an outlier. It is not rejected, just blended in.

FusionCore runs a chi squared test on every sensor update before it touches the filter state. If the Mahalanobis distance is too large for that sensor, the update just gets dropped.

On the November 2012 sequence I mentioned earlier, this actually backfired. GPS degraded for a long stretch, so FusionCore kept rejecting updates and the state drifted. But on the other five sequences, that same gating is what kept bad fixes from corrupting the filter.

My current hypothesis for the RL UKF NaN is something like this. A bad measurement gets fused, even if it is low weight. That nudges the state. The next measurement now looks like a bigger innovation, it also gets fused, and the errors start to stack. Eventually the UKF runs into a numerical issue and you get a singular matrix somewhere along the line.

I would be really interested to hear if you have seen something like that before or if you think there is a different root cause.

What I think actually differs

Based on your corrections, this is my updated view of what FusionCore does differently, not better or worse.

The filter has 22 states. Gyro bias and accelerometer bias are estimated continuously as part of the state, not handled afterward. Over long runs with temperature changes, that makes a difference.

There is per sensor chi squared gating. IMU, wheel odometry, and GPS each have their own thresholds based on their degrees of freedom. RL uses a single global Mahalanobis threshold.

Noise covariance is adapted online from the innovation sequence. In RL it comes from YAML config. In FusionCore it gets updated based on what the sensors are actually doing.

There is ZUPT and an inertial coast mode. As far as I know those are not in RL.

It is a single node setup, no navsat_transform needed. navsat_transform subscribes to odometry filtered to initialize its UTM to world transform, which means the filter has to be running before GPS can come in. The startup ordering issues people hit seem to come from that design rather than misconfiguration.

What robot_localization has that FusionCore does not

Multi robot support, differential and absolute modes, a much bigger user base, and a lot of real world validation over time. And honestly, people like you who know the code well enough to call out mistakes publicly. That part is genuinely valuable.

Thanks again for engaging with this seriously. If anything above is factually wrong I want to know.