~/satyajit

Open robotics mapping roundup: multi-floor SLAM, RTK in C++20, calibration checks and phone-mounted LiDAR

mdjsonmcp

2026-11-12 · 33 min · slam · lidar · state-estimation · point-cloud · robotics · calibration · hardware

A 4:51 narrated explainer, drawn in code. Every number and picture in it is this article's own; the sources are below.

› transcript

Two floors of an office look exactly alike. Why does a perfectly good mapping system fold them into one? Hi, I'm Ondine! Today, an open system that keeps a building's floors apart, and two more tools with the same habit: refusing an answer they cannot support. Chapter one. Why identical floors merge. A robot walks the corridor on floor one. Three metres below, floor zero has the same corridor, scanned earlier. The place recognizer compares descriptors and says, correctly, these two places look the same. Registration checks the match. The walls really do line up, to a few centimetres. So the graph gets a loop factor: precise, consistent, and wrong. It says two places three metres apart are one. Make it one number: floor one's height. The stairs say three metres, give or take fifteen centimetres. One false loop says zero, give or take five centimetres. It outvotes the stairs nine to one, and floor one drops to thirty centimetres. Add the rest of the false loops, and floor one lands on floor zero. A barometer would only slow the slide. Switch the gate on, and the loops are never written. Floor one goes back to three metres, however many matches there were. The project tests this on purpose. Two identical floors, joined by stairs, camera only. With the floor gate switched off by a debug flag, the recognizer proposes thirty five matches across floors. All of them are accepted, along with six real ones. Thirty five of forty one loops join different floors. The map ends with one floor instead of two, and the test passes only because it collapsed. Chapter two. A graph per floor. The idea is blunt. Keep a separate map for every floor, refuse every loop closure between floors, and join the floors later, offline. First, something says the robot left its floor: tracked height, the barometer, or the accelerometer feeling an elevator push. The floor manager waits until everything is calm again. Then it classifies. If the pressure climbed but the camera saw no climb, it was an elevator. Otherwise stairs or a ramp, split by slope. Finally the climb is snapped to a whole number of storeys, so a half landing on the stairs never becomes a floor. Every candidate loop passes through a gate before it reaches a graph. Open it, and there are three separate checks. First, each floor has its own place database, so a scan is only ever compared with its own floor. Second, a loop whose two ends carry different floor labels is rejected. Third, each floor's graph refuses a factor to a node it does not hold. No code path can join two floors. Their demo shows it in simulation. Without the gate, loop closures pile the two floors together. With it, each floor stays in its own map, and the stairs join them. And on real data, a hand-held LiDAR rides an elevator. The accelerometer measures the ride the LiDAR cannot see, the new floor gets its own colour, and the walk back down the stairs is measured by the odometry. Chapter three. The same habit elsewhere. Satellite code ranges give a position to a few metres. Differencing against a base station and a reference satellite cancels both clocks. What remains is a whole number of carrier cycles per satellite, which the filter estimates as real numbers. An integer least squares search finds the best set and the runner up. The library fixes only if the runner up is three times worse. Otherwise it stays float, rather than report false confidence. A calibration checker does the same. Here a LiDAR transform carries an injected yaw error, and the points miss the bollards. It judges the error against the data's own noise and fails the pair. An axis the recording could not observe is marked not measured, never guessed. The floor gate is sound by construction. The rest has thinner evidence. The simulator's camera produces feature points, not rendered images, so the visual front end's real failures are not in those tests. Real multi-floor evidence is one elevator dataset and a few robot bags. And matching walls between floors has not yet contributed a constraint on real data. The common thread: the safest estimator is one that cannot write the wrong answer down. To recap. One map per floor, with no loops between. Integers fixed only past a ratio test. And calibration checked, axis by axis. Every source is in the full article. I'm Ondine. Bye!

Why read this

Notabletop 60%

Five open mapping tools read from code: why identical floors merge and how a gate stops it, RTK integer fixing, calibration checks, phone LiDAR.

  • Original analysis
  • Runs on a laptop CPU
  • Concrete numbers to act on

Robotics & embodiedMixed licencesPractitioner roundup

How this was scored
Is it new?
1 of 3: An incremental tweak
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
1 of 3: Relevant for months
Does it affect many?
1 of 3: A specialist community
Only here?
2 of 3: A teardown or measurement few others did

Score 60 of 100, ranked 443 of 740 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

Five things crossed my feed in the same few days of November 2026, and they turned out to be one subject seen from five heights. A ROS 2 SLAM system that keeps a building's floors apart. A GNSS library that gets a car's antenna to centimetres. A tool that tells you whether the transform between your LiDAR and your IMU is still right. A fifty-dollar time-of-flight board stuck to an iPhone. A Livox Mid-360 plugged into an Android phone. All five are answers to the question every mapping pipeline asks before it can do anything else: where was the sensor when it took this measurement, and how sure are we?

I cloned the five repositories involved, read the parts that do the work and checked the claims against the code, the configs and the datasheets. I did not build or run any of them; where a number comes from a project's own benchmark, I say so in the sentence. The arithmetic in between is mine, and the two widgets below are toys I wrote to show the mechanism, not ports of anyone's solver.

If you work on LiDAR SLAM, the first section is the one to read: StrataSLAM's answer to perceptual aliasing is a structural one, and it is more interesting than its README makes it sound.

StrataSLAM keeps one map per floor and refuses everything else

StrataSLAM is a multi-floor 3D SLAM system for ROS 2 Jazzy from José Miguel Guerrero at Universidad Rey Juan Carlos, released as v0.2.0 under Apache-2.0 at the start of November (the citation file says 2 November; the repository was created on 3 November and had 41 stars when I looked on 12 November). It takes an RGB-D camera, a 3D LiDAR, both, or a 2D laser scanner, with an IMU, a barometer and wheel odometry as optional extras, and it writes one point cloud per floor plus a global one. The README's one-line pitch is "one point cloud per floor and a global one, without the floors collapsing into each other". I read it at commit ec705b1 (10 November).

EasyNavigation/StrataSLAM@ec705b1 · snapshot 2026-11-12
stars
42
license
Apache-2.0
branch
main
forks
3
open issues
0
source
1.3 MB
commit date
2026-11-10
source by language
C++1.0 MBPython263.4 kBShell20.1 kBCMake18.2 kB

GitHub REST API, 2026-11-12 — branch, commit, commitDate, createdAt, forks, languages, license, openIssues, pushedAt, stars

One map per floor, then the same identical-floor building mapped with and without floor gating. This segment is from the simulator, not real data (StrataSLAM's demo video, 0:25 to 0:39).

Why identical floors collapse

Loop closure is the step that makes SLAM more than odometry. The front end proposes "I have been here before" by comparing a descriptor of the current scan or image against a database of past keyframes: a Scan Context ring descriptor for LiDAR, a bag of visual words for a camera. A verification step registers the two scans, and if the fit is good, the back end adds a relative-pose factor between the two keyframes and re-solves the graph. That factor is precise. Two scans of the same corridor registered with GICP agree to a few centimetres, which is exactly why one good loop closure can pull a whole drifted trajectory back into shape (the GTSAM article has a loop you can close by hand).

Now put the robot in an office block where floor 2 has the same corridor, the same doors and the same lift lobby as floor 1. The place recognizer is doing its job when it says floor 2's lobby looks like floor 1's lobby: the descriptors are the same because the geometry is the same. Verification passes too, because the scans really do register. The back end receives a precise, internally consistent, completely wrong constraint saying two keyframes three metres apart vertically are the same place. Repeat that at every lobby and corridor, and the optimizer does the only thing least squares can do: it moves floor 2 down onto floor 1, because dozens of centimetre-precise loop factors outvote one sloppy stair measurement. That is perceptual aliasing, and it is a failure of the problem formulation, not of any component.

You can watch it happen in a stripped-down version. Floor 1's height hh is the only unknown. The stairs say h=3.0h = 3.0 m with σ=0.15\sigma = 0.15 m, which is the z standard deviation StrataSLAM assigns to a stair or ramp transition (its ADR-0015). Every accepted cross-floor loop says h=0h = 0 with the registration's σℓ\sigma_\ell. The least-squares estimate is a weighted mean:

h^  =  hstairs/σt2  +  ∑k=1n0/σℓ21/σt2+n/σℓ2,σh^=(1σt2+nσℓ2)−1/2.\hat h \;=\; \frac{h_{\text{stairs}}/\sigma_t^2 \;+\; \sum_{k=1}^{n} 0/\sigma_\ell^2}{1/\sigma_t^2 + n/\sigma_\ell^2}, \qquad \sigma_{\hat h} = \Big(\tfrac{1}{\sigma_t^2} + \tfrac{n}{\sigma_\ell^2}\Big)^{-1/2}.

With σℓ=0.05\sigma_\ell = 0.05 m one false loop has weight 400 against the stairs' 44.4, so it already outvotes the stairs nine to one and puts floor 1 at 0.30 m. A barometer at σ=0.05\sigma = 0.05 m slows the slide, since it adds a second honest vote, but it is one vote, and the loops keep coming.

The widget's default is the number StrataSLAM's own negative control produces. Scenario S04 is S03 (two identical floors joined by stairs, camera only, no IMU) run with the floor gate switched off by a debug flag. Its report in docs/scenarios.md lists 41 accepted loops, 35 cross-floor candidates, one floor instead of two, an ATE of 1.46 m and a collapse rate of 0.853659. The collapse rate in metrics.cpp is the share of accepted loops whose two ends are on different floors, and 35 / 41 = 0.853659, so every one of the 35 cross-floor candidates was accepted, alongside six loops on the right floor. With the gate on (S03, same building, same seed) the run has 8 loops, all on-floor, two floors, an ATE of 0.022 m and zero cross-floor candidates. Zero candidates, not zero accepted: with the gate on, the recognizer never even proposes one, for a reason that comes up below. The test harness fails the run if the negative control does not collapse, which is the right way round: a test that cannot see the bug cannot prove the fix.

Noticing that you changed floor

Refusing cross-floor loops only works if every keyframe carries the right floor label, so most of StrataSLAM's complexity is in floor_manager.cpp (843 lines), a state machine that decides when the robot has left a floor and where it arrived. Any of three signals can open a transition:

The IMU path is for elevators, where the camera or LiDAR sees a cabin that moves with it and reports no climb at all. The transition closes after two seconds of calm, and is then classified:

The height change is then snapped to a level. With the default floor_height_prior of 3.0 m, a change counts as a floor only when it is within 0.4 of a whole number of storeys, which is how the half-way landing of a U-shaped stair is kept from becoming a floor. Matching an existing level uses a tolerance of 0.4 times the prior (1.2 m), widened by the level's own uncertainty; an elevator ride measured by the IMU is trusted to 5 % of its height. Those numbers came from real data: the comments cite sequences from the Elevator-VIGS dataset by name, such as an "Office4" run that rode 14.75 m up and walked 15.43 m down the stairs, ended 0.68 m off, and opened a phantom third level before the 5 % term was added. That run had no height prior, so the tolerance was 0.4 times the 1.5 m minimum change, 0.6 m, not 1.2 m. A plateau rule (20 s calm while travelling 10 m) also accepts a mezzanine that is no multiple of 3 m.

Real data: the Elevator-VIGS Office4 sequence (Zhou et al., 2026, CC BY-NC 4.0). A hand-held LiDAR and IMU rig rides an elevator the IMU detects, then walks back down the stairs, which the odometry measures; each floor gets its own colour (StrataSLAM's demo video, 0:49 to 1:00).

Three layers of refusal

The floor gate is three independent checks, and the code names them:

  1. Per-floor place recognition. Each floor has its own Scan Context database and its own bag-of-words index (scan_context.hpp: "the caller owns one instance per floor (gating layer 1)"). A query only searches the current floor, so a keyframe on floor 2 is never compared with floor 1. This is why S03 reports zero cross-floor candidates.
  2. Label check. Backend::verify_loop_allowed rejects a loop whose two keyframes carry different floor labels, or any keyframe without a label.
  3. Graph membership. There is a separate factor graph per floor, and FloorGraph::add_loop refuses a factor to a node it does not hold. ADR-0040 records that the original layer 3 just re-compared the labels, so a mutation test could delete it unnoticed; it was rewritten to check what the factor actually joins.

The consequence is stronger than "we filter false loops". No online code path can write a cross-floor factor, so the failure in the widget cannot happen however similar two floors look. What can still go wrong is the floor label itself: a missed transition maps two floors into one graph, and a phantom transition splits one floor in two. Every hard case in the floor manager's comments is one of those.

Putting the floors back together, offline

Each floor is then a separate map with its own frame, and the building has to be assembled. strataslam fuse does it offline in a gravity-aligned four-degree-of-freedom group, SE(2)×R\mathrm{SE}(2)\times\mathbb{R}: yaw, xx, yy and zz, with roll and pitch fixed by gravity. The documentation in docs/math/pose4.md follows Solà, Deray and Atchuthan's micro Lie theory notation. A pose X=(θ,t)X = (\theta, \mathbf t) composes as

X1X2=(θ1+θ2,  Rz(θ1) t2+t1),exp⁡(φ,ρ)=(φ,  (V(φ) ρxy, ρz)),X_1 X_2 = \big(\theta_1+\theta_2,\; R_z(\theta_1)\,\mathbf t_2 + \mathbf t_1\big), \qquad \exp(\varphi, \boldsymbol\rho) = \big(\varphi,\; (V(\varphi)\,\boldsymbol\rho_{xy},\, \rho_z)\big),

which is SE(2) with a free height bolted on. The constraints between floors are the transition records (the relative pose measured across the stairs or the ride, with a covariance that grows 3 % per metre the odometry travelled), the barometer, user-placed anchors, a weak floor-height prior with σ\sigma = 0.10 m, and a structural registration that correlates rasterised wall slices of two floors.

That last one is where aliasing comes back, and the project handles it honestly. Registering two floors by their walls cannot tell one level seen twice from two identical levels stacked. ADR-0027 reports that on a Gazebo garage with near-identical floors, every pair was declared ambiguous (best score 0.996 against a runner-up of 0.97) and contributed nothing, which is the correct outcome. ADR-0031 adds "same-level candidates": pairs of floors whose structure matches and whose heights differ by under 2 m are listed as warnings and never applied. On one real bag it listed three candidates, one of them right. The user confirms which with --same-level. A candidate list is advice, not a classification, in the ADR's own words.

Split screen of the same simulated two-storey building. Left panel, labelled without floor gating: loop closures merge the two floors and their walls are drawn on top of one another. Right panel, labelled StrataSLAM: each floor stays in its own map, cyan below and magenta above, with the stairs between them.
Identical floors with and without floor gating, in simulation: on the left loop closures merge the two floors, on the right each stays in its own map (StrataSLAM's demo video, 0:34).

What the evidence covers

The fifteen synthetic scenarios run on 20 seeds each, and docs/results.md reports every failure with its seed: S07 (an elevator with no IMU or barometer, fused with anchors) passes 13 of 20 on a 0.10 m translation bound; S13 (a 2.9° ramp) fails once at 0.2512 m against 0.25 m. S15, four identical floors up by elevator and back down by the stairs, passes but with a mean floor-height error of 0.4507 m. Read the simulator results with ADR-0017 in mind: the camera is simulated at feature level, as keypoints with descriptors and depth, not rendered images, so the visual front end's real failure modes are not in them.

The real-data evidence is thinner and is said to be. On TUM RGB-D fr1/xyz and fr2/xyz the visual odometry gets 0.0143 m and 0.0152 m ATE. On KITTI drive 0027 (692 m) the full LiDAR pipeline gets 0.40 m ATE with one loop closure at 52 ms a scan on one thread. The multi-floor claims rest on Elevator-VIGS: ADR-0059 reports that on its 16 real sequences one-way rises land within 0.06 to 0.9 m of the truth and round trips close within 0.07 to 2.6 m. The structural floor registration has, per ADR-0027, "not yet contributed a constraint" on real data. None of that undercuts the gate: refusing cross-floor loops does not need a benchmark to be correct. It does mean the hard part, labelling floors correctly on a real robot in a real stairwell, has been tested on far fewer buildings than the scenario table suggests. A multi-floor equivalent of the Hilti SLAM datasets, graded against surveyed points, would settle it.

Forty-one stars and a week old, with 69 ADRs (each deviation from its own plan written down with a reason), a negative control in CI and mutation testing on the gate. I would try it on a multi-storey capture before I would trust a single-graph SLAM system to keep two identical car-park levels apart.

libgnss++: from a thirty-metre guess to a centimetre fix

libgnss++ is Ryohei Sasaki's C++20 GNSS toolkit (MIT, 260 stars on 12 November, read at 1f7bd1e, version 0.6.0). It implements single-point positioning (SPP), real-time kinematic (RTK), precise point positioning (PPP), Japan's CLAS and MADOCA correction services, RTCM, UBX and QZSS L6 decoding, a GTSAM-backed GNSS/IMU factor graph, Python bindings, a ROS 2 player and a WebAssembly build that runs gnss solve in a browser tab. Its src/ directory alone is about 104,000 lines. It is a lot of library, and the core of it is two pieces of estimation worth understanding.

rsasaki0109/gnssplusplus-library@1f7bd1e · snapshot 2026-11-12

Modern C++20 GNSS toolkit — RTK/PPP/CLAS/IMU fusion.

stars
260
license
MIT
branch
develop
forks
27
open issues
1
source
19.8 MB
commit date
2026-11-11
source by language
C++12.7 MBPython6.4 MBHTML334.2 kBJavaScript257.6 kBCMake89.2 kBMATLAB69.1 kBCSS14.9 kB
c-plus-plusgnssimulocalizationpositioningpppqzssros2

GitHub REST API, 2026-11-12 — branch, commit, commitDate, createdAt, description, forks, homepage, languages, license, openIssues, pushedAt, stars, topics

Pseudoranges and four unknowns

A receiver measures, for each satellite ss, a pseudorange: the signal's travel time times the speed of light, biased by the receiver's clock.

Ps=∥ps−x∥+c δtr−c δts+Is+Ts+εs.P^s = \lVert \mathbf p^s - \mathbf x \rVert + c\,\delta t_r - c\,\delta t^s + I^s + T^s + \varepsilon^s .

The satellite position ps\mathbf p^s and clock δts\delta t^s come from the broadcast ephemeris, the ionospheric delay IsI^s and tropospheric delay TsT^s from models. That leaves four unknowns: the position x\mathbf x and the receiver clock bias c δtrc\,\delta t_r. Linearise around a guess x0\mathbf x_0, with es\mathbf e^s the unit vector from receiver to satellite, and each satellite gives one row of a linear system:

Ps−P^s(x0)⏟rs=[−es⊤1][Δxc δtr],Δ=(H⊤WH)−1H⊤W r.\underbrace{P^s - \hat P^s(\mathbf x_0)}_{r^s} = \begin{bmatrix} -\mathbf e^{s\top} & 1 \end{bmatrix} \begin{bmatrix}\Delta\mathbf x \\ c\,\delta t_r\end{bmatrix}, \qquad \Delta = (H^\top W H)^{-1} H^\top W\,\mathbf r .

Iterate until Δ\Delta stops moving. Four satellites determine it, more over-determine it, and WW down-weights low satellites whose signals cross more atmosphere. spp.cpp is this loop (3,007 lines once you add multi-constellation clock offsets, robust re-weighting and satellite exclusion modelled on MRTKLIB's estpos), starting cold from a seed about 24,000 km out. The answer is good to a few metres, limited by the code measurement's noise and the atmosphere models.

Carrier phase and the integer problem

Centimetres need the carrier phase. The receiver tracks the phase of the 1575.42 MHz L1 carrier, whose wavelength is λ=c/f\lambda = c/f = 0.1903 m, to a small fraction of a cycle. But it only knows the fractional part when it locks on: the whole number of cycles between satellite and antenna, NsN^s, is unknown.

λ Φs=∥ps−x∥+c δtr−c δts−Is+Ts+λNs+ϵs.\lambda\,\Phi^s = \lVert \mathbf p^s - \mathbf x \rVert + c\,\delta t_r - c\,\delta t^s - I^s + T^s + \lambda N^s + \epsilon^s .

RTK removes almost everything else by differencing. Subtract the base station's measurement from the rover's (the satellite clock cancels), then subtract a reference satellite's single difference from each other satellite's (the receiver clocks cancel). Over a short baseline the atmosphere is nearly the same at both receivers and cancels too:

λ ∇ΔΦs=∇Δρs(b)+λ ∇ΔNs+ϵ.\lambda\,\nabla\Delta\Phi^{s} = \nabla\Delta\rho^{s}(\mathbf b) + \lambda\,\nabla\Delta N^{s} + \epsilon .

What is left is the baseline b\mathbf b and one integer per satellite pair. In libgnss++, as in RTKLIB, an extended Kalman filter carries the baseline, velocity, optional ionosphere terms and one single-difference ambiguity per satellite and frequency; rtk_filter.cpp forms each double-difference phase row from two of those states (λrefNref−λsNs\lambda_\text{ref} N_\text{ref} - \lambda_s N_s). The filter's ambiguities are real numbers, the float solution, with a covariance Qa^Q_{\hat a}. Snapping them to integers is integer least squares:

aˇ=arg⁡min⁡a∈Zn  (a^−a)⊤Qa^−1 (a^−a).\check{\mathbf a} = \arg\min_{\mathbf a \in \mathbb Z^n}\; (\hat{\mathbf a} - \mathbf a)^\top Q_{\hat a}^{-1}\,(\hat{\mathbf a} - \mathbf a).

Rounding each a^i\hat a_i on its own is wrong whenever the ambiguities are correlated, and double differences always are: they share the reference satellite, and over a few seconds the satellites barely move, so Qa^Q_{\hat a} is a long, thin, tilted ellipse. Teunissen's LAMBDA method decorrelates first, with an integer, volume-preserving transform ZZ (built from integer Gauss transforms and permutations of an LDL⊤LDL^\top factorisation), so the search in the transformed space is small, then maps the answer back with Z−⊤Z^{-\top}. libgnss++'s lambda.cpp says at its head that it is "Ported line-by-line from RTKLIB's lambda.c" (BSD-2-Clause, T. Takasu), with Eigen in place of C arrays. That is a good choice: it is the most exercised LAMBDA in open source, and the README's "without an external RTKLIB runtime" is true while the heritage is plain.

The search returns the best and second-best integer vectors with costs q1≤q2q_1 \le q_2. The ratio test accepts the best only if

q2q1  ≥  κ,\frac{q_2}{q_1} \;\ge\; \kappa ,

with κ\kappa = ambiguity_ratio_threshold = 3.0 by default (rtk.hpp). Optional modes make it 1.5 with 20 or more satellites, 2.0 with 15 or more, 2.5 with 10 or more, and 2.0 while an ambiguity is being held after a run of fixes. There is also a fixed-failure-rate ratio table from Hou, Verhagen and Wu (Sensors 2016) and partial fixing of a subset. Once the integers are accepted, the baseline is conditioned on them, bˇ=b^−Qb^a^Qa^−1(a^−aˇ)\check{\mathbf b} = \hat{\mathbf b} - Q_{\hat b \hat a} Q_{\hat a}^{-1}(\hat{\mathbf a} - \check{\mathbf a}), and the same formula appears as xa = head_state - sQab * slv.solve(sf - sx) in rtk_ar_resolve.cpp.

The toy shows the three regimes. With few epochs (noise run 3, four epochs), rounding picks (1, 1) while integer least squares already finds (0, 0), because it measures distance along the ellipse. Run 2 at one epoch is the dangerous one: the best integer is (−3, −2), wrong, and its ratio is 3.1, so a threshold of 3.0 accepts it. A wrong fix is worse than staying float, because the solution then reports centimetre confidence about a position that can be off by a wavelength per satellite; that is why the PPC tables below count wrong fixes separately.

Ground truth (white dashed), libgnss++ default settings (mint) and RTKLIB demo5 (orange) on a selected UrbanNav Odaiba segment. The project's README calls it a favourable selected segment, not a full-run benchmark (libgnss++ README).

What the benchmark tables say, and what they do not

The README leads with that Odaiba animation: median horizontal error 0.38 m against RTKLIB demo5's 7.41 m over 172 common epochs. The README itself says it is "a favorable selected segment, not a full-run benchmark", and the hero's notes (docs/odaiba_hero.md) record a correction made on 26 October: an earlier version ran RTKLIB GPS-only by a config mistake, and with every constellation RTKLIB fixed 147 of that window's 201 epochs, so the window was re-selected. On the full drive the honest comparison is the "Urban RTK" row: with --preset urban, 7,467 fixes against demo5's 5,839 with fewer wrong fixes, while the default profile fixes only 1,298 epochs.

The PPC 2024 table (six Tokyo and Nagoya runs) reports a macro fix rate of 70.939 % against 54.326 % for gici-open, with 1.463 % wrong fixes. Two caveats are printed next to it: these are "in-sample benchmark results, not a held-out generalization claim", and gnss reproduce ppc-goal rescores 26 frozen tier inputs whose solver commands were not recorded, so the table "cannot be regenerated from scratch". The Smartphone Decimeter Challenge score of 0.714 m private is a late, unranked submission reached after per-drive Kaggle probes, again as the README states.

Three grouped bar charts over six PPC runs, Tokyo 1 to 3 and Nagoya 1 to 3, plus a macro average: fix rate, correct fix over reference, and the PPC official score. libgnss++ in teal is higher than gici-open in grey on most runs, but gici-open has the higher official score on Tokyo 1, Tokyo 2 and Nagoya 1.
PPC 2024 six-run comparison against gici-open on the same data and metrics. The README calls these in-sample results (libgnss++ README, PPC comparison figure).

I like this README more than most because it publishes its own corrections. It also has the shape of a project tuned against the benchmarks it reports, with an AGENTS.md at the root and docs named like work packages. For a positioning engineer the library's value is the RTKLIB-compatible core in modern C++ with a factor-graph back end beside it, not the leaderboard rows.

Calibrex checks a calibration instead of trusting it

Calibrex, from the same author (Apache-2.0, 62 stars, read at 2f9d8d0 on 12 November, labelled alpha), starts from a question most calibration tools skip. You calibrated the rig once. Is the transform in /tf_static still right after someone remounted the IMU? It has three commands: calibrex estimate reads a bag and estimates each sensor pair's transform from the motion alone, with no target; calibrex check compares a deployed calibration against that estimate on another recording; calibrex drift asks whether it changed across bags. It reads rosbag2, MCAP and ROS 1 bags without a ROS install, and covers IMU-LiDAR, camera-IMU, camera-LiDAR (rotation only), LiDAR-LiDAR, GNSS lever arms and vehicle frames.

rsasaki0109/Calibrex@2f9d8d0 · snapshot 2026-11-12

Check whether the sensor calibration on your robot is still right: one command, honest per-axis verdicts for LiDAR, IMU, camera, GNSS, and vehicle.

stars
62
license
Apache-2.0
branch
main
forks
5
open issues
0
source
17.3 MB
commit date
2026-11-12
source by language
Python17.2 MBJavaScript81.1 kB
camera-imu-calibrationcamera-lidarextrinsic-calibrationgnssimulidarlidar-camera-calibrationlidar-imu-calibration

GitHub REST API, 2026-11-12 — branch, commit, commitDate, createdAt, description, forks, homepage, languages, license, openIssues, pushedAt, stars, topics

The IMU-LiDAR rotation solver shows the method in one equation. Between consecutive LiDAR odometry poses RiR_i and Ri+1R_{i+1}, the mean angular rate in the LiDAR frame is ωL=Log(Ri⊤Ri+1)/Δt\boldsymbol\omega_L = \mathrm{Log}(R_i^\top R_{i+1})/\Delta t. The gyro measured the same rotation in its own frame, shifted by a clock offset δ\delta and biased by b\mathbf b:

ωL  =  RLI (ωˉgyro[ti+δ,  ti+1+δ]−b).\boldsymbol\omega_L \;=\; R_{LI}\,\big(\bar{\boldsymbol\omega}_\text{gyro}[t_i+\delta,\; t_{i+1}+\delta] - \mathbf b\big).

Calibrex initialises RLIR_{LI} with a weighted Procrustes alignment of the two sets of rates (so any mounting works), then solves for rotation, offset and bias with a nonlinear least squares in which the interval means of the gyro come from a cumulative trapezoid integral. Rotation about an axis is only observable if the recording rotates about the other axes: a car that only yaws says nothing about the yaw of its IMU mount.

The lever arm (translation) is harder, and the docs explain why. It enters only through rotation: (Rj−Ri) l=Ri(Exp(θ)−I) l(R_j - R_i)\,\mathbf l = R_i(\mathrm{Exp}(\boldsymbol\theta) - I)\,\mathbf l, so the information on axis aa scales with the rotation perpendicular to aa. A planar vehicle can see the horizontal components of a GNSS antenna's lever arm from its turns, and never the vertical one. When an axis is not observed, Calibrex writes it as NOT MEASURED instead of inventing a number.

The verdict is a rule you can audit in check/verdict.py. For each judged axis with error δ\delta between the deployed transform and the estimate, the tolerance is max⁡(3σ,floor)\max(3\sigma, \text{floor}), with a floor of 0.5° for rotation and 0.02 m for translation. The axis passes if ∣δ∣|\delta| is within the tolerance, fails beyond twice it, and warns in between. An axis the data did not observe is listed as unchecked, and a pair with no judged axis at all is "inconclusive".

A KITTI street image with LiDAR points projected onto it, coloured by distance. With a 3 degree yaw error injected into the LiDAR transform, the points beside the bollards miss them. A panel reads calibrex check, lidar-vehicle, FAIL, yaw error 3.31 degrees against a 0.50 degree tolerance, and pitch also judged at 0.52 degrees, a warning.
calibrex check on a KITTI drive with a 3° yaw error injected into the deployed LiDAR transform: yaw is 3.31° from what the data says, 6.6 times the 0.50° tolerance, so the pair fails. Restoring the vendor calibration file passes. KITTI imagery, CC BY-NC-SA 3.0 (Calibrex README animation, first frame).

That frame checks against the rule: 3.31 / 0.50 = 6.6, beyond the factor of 2, so fail; the pitch error of 0.52° is between 0.50° and 1.0°, so warn, which is what the panel prints. The README's other demos are all on public data with their own numbers: a second Ouster on NTU VIRAL snapping from a 19.1 cm to a 4.4 cm median point-to-plane residual (the design value gives 4.6 cm), and camera-IMU on Hilti 2022 landing 0.45° and −0.21 ms from Kalibr's target-based result. On camera-LiDAR, the README says the check flagged 23 of 27 perturbed candidates and never flagged the unmodified reference, and calls that development evidence, not a claim.

One 0.1 s Mid-360 sweep at 180°/s, raw and deskewed with Calibrex's own IMU-LiDAR estimate: surface thickness 3.42 cm raw, 2.19 cm deskewed (Calibrex README animation, RTK-SLAM data, CC BY 4.0).

The deskew clip is the clearest argument for caring. A Mid-360 sweep takes 100 ms; at 180°/s the head turns 18° during it, and every point has to be moved to one instant using the IMU, which is only as good as the IMU-LiDAR rotation and time offset. The same deskew happens at the start of FAST-LIO2; a wrong extrinsic there smears every scan before the filter ever sees it. A tool that tells you, per axis, that your extrinsic has moved by more than the data's own noise, and also tells you when your recording could not have detected it, is the kind of thing I would put in a fleet's nightly checks.

Fifty dollars of time-of-flight on an iPhone

On 10 November Laan Labs posted a short video: "Testing 3d scanning with this ~$50 lidar sensor (ST VL53L9CX) attached to an iPhone. Works pretty well for quick indoor scans using just ARKit poses." It had about 67,000 views two days later, and @taziku_co, the CEO of the Japanese studio taziku, reposted the same video the next day under a line that translates as "how far can 3D scanning go with a roughly $50 small LiDAR and a phone?".

The VL53L9CX board on an iPhone, meshing a living room from ARKit poses and then a whole apartment; the app's status line reads 54x42 at 15 fps (video from @laanlabs's post on X).

The sensor is ST's VL53L9CX, and its datasheet (DS14879 Rev 7, July 2026, included in the board's repository) is specific. It is a direct time-of-flight module: two 940 nm VCSELs flood the scene, and a SPAD array (single-photon avalanche diodes) times the returning photons. Distance is d=c t/2d = c\,t/2, so one nanosecond of round trip is 15 cm, and the module gets its accuracy by building a histogram of photon arrival times over many pulses per zone and finding the peak on chip. The specs:

The resolution is the number to keep in mind. 55° across 54 zones is about 1° per zone, so at 3 m one depth value covers about a 5 cm square of wall. That is a coarse depth camera, not a scanning LiDAR, and it explains how the scans look: good room shape, soft edges on everything small.

The board is Remi De Coster's USB-LiDAR-VL53L9 (MIT for his hardware and code, 136 stars, read at 39a2dd4). It is a 34 × 30 mm KiCad 10 PCB with the VL53L9CX, an ST LSM6DSV16BX 6-axis IMU and an STM32C552 Cortex-M33 that enumerates as a USB CDC serial device.

Block diagram: the VL53L9 dToF 3D LiDAR connects over I3C, and the LSM6DSV 6-axis IMU over SPI, to an STM32C55xx Arm Cortex M33 microcontroller, which connects over USB to a USB-C Full Speed USB port.
The board's architecture: sensor on I3C, IMU on SPI, one Cortex-M33, USB Full-Speed out (USB-LiDAR-VL53L9 README, block diagram).

The firmware does very little on purpose. It triggers a frame, polls the ready flag every 500 µs, reads the raw 14,842-byte frame (a _Static_assert pins the size for binning 2) into one of two ping-pong buffers and ships it in a framed packet with a sequence number, a microsecond timestamp and a CRC-32. The README measures about 35 frames a second and 520 kB/s; 14,842 bytes times 35 is 519,470 bytes a second, so the two agree, and that is about a third of USB Full-Speed's 12 Mbit/s. IMU samples go out at 120 Hz in the same stream. Depth is not computed on the board: the host turns raw frames into depth with ST's processing library and the sensor's factory calibration, which the bundled web app compiles to WebAssembly. That library is under ST's SLA0111 licence, for use with ST sensors only, so the pipeline is open hardware with a closed-source step in the middle. That is normal for ST's ToF parts and worth knowing before you plan to port it.

Photograph of the assembled board: a small green PCB with notched corners for mounting screws, the black VL53L9CX module with its two windows on the left, a microcontroller with a red LED lit beside it, and a USB-C cable plugged into the right edge.
The assembled board, 34 × 30 mm, with M3 holes on a 20 mm pitch for drone stacks (USB-LiDAR-VL53L9 README, assembly photo).

Laan Labs' iPhone app is not in that repository and has not been published, so what follows is only what the video and their replies show. The app's status line reads "VL53 → mesh · edges off · 54x42 · 15 fps", so it ran full resolution at 15 frames a second, 34,020 depth values a second. The pose comes from ARKit, Apple's visual-inertial odometry, not from the ToF sensor; the sensor contributes depth only. That is the easy half of mapping: with poses given, fusing depth frames into a volume or mesh is a solved problem, and the scan's quality is bounded by ARKit's drift and by how well the depth frames are registered to the phone's camera. In the replies, Laan Labs said indoor results look similar to the iPhone's own LiDAR, the iPhone's works much better outdoors, and the VL53L9CX "might have a bit more range indoors", adding that the iPhone pipeline also has ML depth upsampling this demo does not. Two people asked the questions I would ask: how the sensor's timestamps are synced to ARKit, and how the extrinsic to the camera was calibrated. Neither had an answer among the replies I could fetch through a mirror on 12 November. Both are exactly the IMU-camera problem Calibrex measures, at the scale of a phone case.

The "~$50" is Laan's figure. The repository's bill of materials lists parts and Digi-Key numbers but no prices, so I cannot check it; the author says he is only exploring selling assembled units.

The other phone post is from @sm0219370893504 (schan, CTO of IndustryAlpha), also on 10 November: lately he has been playing with 3D point clouds everywhere, DOOM-style, "so for now I connected a phone to a mid360 and did 3D SLAM" (my translation from the Japanese). In the replies he confirms the sensor is a Livox Mid-360, "a compact 360° 3D LiDAR with a built-in IMU".

A Livox Mid-360 tethered to an Android phone over Ethernet, building a map on the phone. The overlay shows 145k points/s from the Mid-360 and its IMU at 200 Hz (video from @sm0219370893504's post on X).

The 11.6-second clip is a phone on a table, with an Android navigation bar, so not an iPhone. Its status chips read "Ethernet", "Mid-360 145k点/s" and "IMU 200Hz", and a counter shows 37,844 points four seconds into a map. The Mid-360's spec page lists 200,000 points a second, a 360° × 59° field of view (−7° to 52° vertical), 40 m range at 10 % reflectivity, a built-in ICM40609 IMU, 100BASE-TX Ethernet, 265 g and 6.5 W from a 9 to 27 V supply. My reading, not something the post says: the phone talks to the LiDAR over a USB-Ethernet adapter, the LiDAR runs off its own supply, and the phone does the computing.

The post names no SLAM algorithm and links no code. A LiDAR with a built-in IMU and a scan pattern that never repeats is the textbook input for a tightly coupled LiDAR-inertial odometry like FAST-LIO2, and running one on a phone-class ARM SoC at a Mid-360's rate is plausible, but that is my guess, not something the post says.

One reply under it linked Foadsf/parallax, and it reads as if it might be related. It is not. The same account posted the same link under Laan Labs' video ("What if you wouldn't need extra hardware?"). Parallax (GPL-3.0, created 2 November, read at a1bc267) is an Android app that does photogrammetry on the phone: RootSIFT features, five-point RANSAC, incremental structure from motion with a Schur-complement bundle adjustment that self-calibrates each lens and models rolling shutter, then semi-global matching for dense stereo, with metric scale from two rear cameras' factory baseline or a printed ArUco marker. A case-insensitive search of the repository for "lidar", "livox" and "mid360" finds nothing. It is a reasonable piece of work about a different problem, camera-only object scanning, and it has nothing to do with the Mid-360 post.

Where this leaves a LiDAR person

The two phone demos are the fun ones and the least informative. Both delegate the hard part: Laan Labs to ARKit, schan to an unnamed odometry on hardware that costs more than the phone. Both are still the right demos to build. A 54 × 42 zone sensor at fifty dollars that ranges 8.8 m indoors is a real new option for close-range fill on a capture rig, next to the survey-grade heads in Hesai's Kosmo or the 360-camera rigs in Gaussian-splat capture in practice, and the VL53L9CX board is the cleanest way I have seen to get raw frames off one, licence caveat and all.

libgnss++ is a serious library with honest documentation and benchmark claims you should treat as the author's own tuning, which the author says. Its RTK core is RTKLIB's mathematics in modern C++, and the widget above is how that core decides when to believe itself.

Calibrex and StrataSLAM are the two I would actually spend time on. They share a habit: each refuses to output something it cannot support. Calibrex will not write a lever-arm axis it did not observe. StrataSLAM will not write a factor between two floors, and lists "these two floors might be the same level" as a warning for a human rather than applying it. Aliasing on repeated floors and drifted extrinsics share a nasty property: the map comes back wrong while every residual looks fine. Both projects attack them by making the wrong answer impossible to write down, rather than by making the estimator cleverer. That is the right instinct. StrataSLAM still has to prove its floor labelling on more real buildings than Elevator-VIGS and a few Summit bags, and I would want to see that before putting it in front of a customer. The gate itself I would take today.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Open robotics mapping roundup: multi-floor SLAM, RTK in C++20, calibration checks and phone-mounted LiDAR", ai.satyajit.si, November 2026.

bibtex
@misc{ghana2026openroboticsmappingroundup,
  author = {Satyajit Ghana},
  title  = {Open robotics mapping roundup: multi-floor SLAM, RTK in C++20, calibration checks and phone-mounted LiDAR},
  url    = {https://ai.satyajit.si/articles/open-robotics-mapping-roundup},
  year   = {2026}
}
share