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.
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.
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