Inside prism_loc: one map→odom contract for three localizers

A case study on prism_loc, pluggable LiDAR localization for ROS 2. Why the estimator cores stay middleware-free, how a single map→odom contract lets three very different estimators swap in place, and what that design costs.

prism_loc is a pluggable LiDAR localization stack for ROS 2. It ships three estimators behind one interface: 2D Monte Carlo localization (MCL), 3D NDT-MCL, and a LiDAR + IMU + RTK-GNSS error-state Kalman filter (ESKF), all over middleware-free C++17 cores, with a single map→odom contract at the output. This is the reasoning behind that shape.

1. The problem: localizers get welded to one algorithm and to ROS

Most localization code starts life fused to two things at once: the specific estimator it was written for, and the middleware it was first deployed on. A 2D particle filter grows ROS message types, TF lookups, and parameter-server calls deep inside its update loop. When the next robot needs a 3D method, or a GNSS-aided filter for an outdoor run, the "estimator" turns out to be inseparable from the transport, and the work restarts.

So the goal for prism_loc was not "write a better particle filter." It was to make the estimator a self-contained thing you can hold in your hand (swappable, testable in isolation) while the choice of 2D versus 3D versus fused becomes a configuration decision, not a rewrite.

2. One contract: map→odom

The design pivots on a single shared output. Whatever estimator is running, its job is to produce one thing: the correction from the map frame to the odometry frame. Odometry already carries the robot forward continuously; localization's real responsibility is only to keep that dead-reckoned frame pinned to the map. Naming that as the one contract every backend must satisfy is what makes the backends interchangeable.

The lesson I'd share: find the one output everything must produce, and make it the contract. Once map→odom is the interface, a 2D filter and a GNSS-aided 3D filter are the same kind of object to the rest of the system.

3. Three localizers behind the same seam

With the contract fixed, three estimators that share almost no math can live behind the same seam:

  • 2D MCL. A particle filter against an occupancy grid, the reliable default for planar indoor robots on a known floor.
  • 3D NDT-MCL. Monte Carlo localization scored with the normal-distributions transform, for multi-storey and full-6-DoF settings where a flat-floor assumption breaks.
  • LiDAR + IMU + RTK-GNSS ESKF. An error-state Kalman filter that fuses scan geometry with inertial prediction and RTK-GNSS, for runs where an absolute reference is available and drift has to be bounded over long distances.

These are genuinely different estimators: different state, different sensors, different failure modes. What lets them coexist is that none of them owns the interface; they all just emit map→odom.

Architecture diagram of prism_loc: middleware-free C++17 estimator cores for MCL and ESKF fusion at the base, the ROS 2 nodes and adapters wrapping them in the middle, and the single map→odom output contract at the top.
The shape in one picture: the middleware-free C++17 cores (2D/3D MCL and the ESKF fusion) sit at the base, the ROS 2 nodes and adapters wrap them in the middle, and every backend converges on the same map→odom output contract at the top.

4. Middleware-free cores, ROS 2 at the edges

Each estimator core is plain C++17 with no dependency on the middleware. ROS 2 lives at the edges (subscriptions in, the transform out) as a thin adapter wrapped around a core that never imports it. That boundary is a deliberate choice, and it pays for itself twice.

First, testability. A core that takes scans and poses and returns a correction can be exercised directly, deterministically, without spinning up a graph of nodes. That is what makes it honest to say every claim is backed by tests and committed evaluation artifacts rather than a one-off demo: the thing being evaluated is a pure function of its inputs.

Evaluation plot for prism_loc: position error over time on top and yaw error over time on the bottom, each traced for three random seeds.
One of the committed evaluation artifacts: position error (top) and yaw error (bottom) over time for a run, repeated across three random seeds.

Second, portability. The estimator is not hostage to one distro's message types or transport. The middleware can move underneath it and the math does not care.

5. What it costs

This shape is not free, and pretending otherwise would be dishonest engineering.

  • A boundary you have to maintain. Keeping the core middleware-free means writing and owning the adapter layer that a tightly-coupled node would get "for free." That is real surface area.
  • A contract narrow enough to constrain. Committing to map→odom as the one output is a feature precisely because it is restrictive; an estimator that wanted to publish something fundamentally different would have to justify widening the contract rather than quietly bypassing it.
  • Three backends to keep honest. Pluggability invites breadth, and breadth has to be paid for in tests and evaluation artifacts for each backend, not just the one you happened to run last.

What I'd carry forward

The durable idea here is not the particle filter or the NDT scoring; it is the discipline of naming a single output contract and refusing to let any one algorithm own it. That is what turned "a localizer" into a place you can plug localizers. The same move generalizes: its sibling project applies the identical middleware-free-core, ROS-2-at-the-edges split to lifelong mapping.


prism_loc is public and Apache-2.0. Source, tests, and evaluation artifacts: github.com/kjungmo/prism_loc