Outdoor & Large-Scale Navigation
Outdoors, the floor tilts, the world moves, the map is measured in kilometres, and the sky offers both a gift (satellites) and a curse (weather). This phase upgrades the indoor stack for all four.
7.1What Breaks Outside
The indoor stack of Phases 2–5 rests on assumptions so convenient they were never written down. Outdoors cancels them in bulk:
| Indoor assumption | Outdoor reality | The upgrade (this chapter) |
|---|---|---|
| The floor is flat; 2D pose suffices | slopes, curbs, ruts, grass — z, roll, pitch matter | 2.5D elevation mapping + traversability (7.5) |
| The world holds still | cars, people, carts everywhere; yesterday’s parked truck is gone | dynamic removal & lifelong maps (7.6) |
| Drift is fixed by loop closure | routes are kilometres with few revisits | absolute positioning: RTK-GNSS in the graph (7.2–7.4) |
| Lighting is benign, appearance stable | sun angle, night, rain, snow, seasons | appearance-robust sensing & retrieval (7.7) |
7.2GNSS: From Satellites to Centimetres
A GNSS receiver measures pseudoranges — signal travel time × c to each visible satellite — and solves for position plus its own clock error. Standalone accuracy is metres, and the error budget explains why: ionospheric and tropospheric delays, satellite clock and orbit errors, and multipath (signals bouncing off buildings before arriving). The route to centimetres is differential: a nearby base station at a surveyed position observes the same errors, and broadcasting corrections cancels everything the two receivers share:
pseudorange: ρᵢ = ‖ sᵢ − r ‖ + c·δt_rx + iono + tropo + multipath + ε
RTK (real-time kinematic): use the carrier PHASE (~19 cm wavelength, measured
to ~1%) instead of the code. Double-difference across two receivers and two
satellites cancels both clocks and the shared atmosphere — leaving geometry
plus an INTEGER number of unknown wavelengths (the ambiguity):
fix = ambiguities resolved to integers → ~1–2 cm (trust it)
float = ambiguities still real-valued → dm-level (suspect it)
corrections arrive over internet (NTRIP casters) or radio; RTKLIB is the
standalone open-source engine for all of it.
Operationally, the receiver’s fix/float/single status is a first-class input to fusion, not a footnote: an RTK-fixed position deserves centimetre-grade covariance; a float solution deserves decimetres and suspicion; and in an urban canyon, a "fixed" solution that jumps three metres between epochs is multipath wearing a confident face — which is what the gating in 7.4 exists to catch.
7.3Frames for the Planet: WGS84 → ECEF → ENU
GNSS speaks latitude, longitude, height on the WGS84 ellipsoid; every controller and costmap in this book speaks metres in a flat local frame. The conversion is two exact steps, and doing them properly (not with degree-to-metre folklore) is a roadmap item in its own right:
step 1 — geodetic (φ, λ, h) → ECEF (Earth-Centered Earth-Fixed):
a = 6378137.0, f = 1/298.257223563, e² = f(2−f) (WGS84)
N(φ) = a / √(1 − e²·sin²φ) prime-vertical radius
X = (N + h)·cosφ·cosλ
Y = (N + h)·cosφ·sinλ
Z = (N·(1 − e²) + h)·sinφ
step 2 — ECEF → ENU about a reference point r₀ (the "map origin"):
d = p_ecef − r₀_ecef
[E] [ −sinλ cosλ 0 ]
[N] = [ −sinφ·cosλ −sinφ·sinλ cosφ ] · d
[U] [ cosφ·cosλ cosφ·sinλ sinφ ]
pick ONE reference r₀ per site, convert everything, and never do arithmetic
in degrees. The local tangent plane is honest to ~10 km; beyond that,
re-anchor or account for curvature.
Setup A: a WGS84 → ENU converter — one evening, zero dependencies. Goal: implement the two exact steps, verify them against known geometry, and measure what the spherical-Earth shortcut costs — the bug that ships in more outdoor robots than anyone admits. Save as wgs84.cc, compile with g++ -O2 -std=c++17 wgs84.cc -o wgs84:
// wgs84.cc — geodetic → ECEF → ENU, exactly; then the spherical shortcut.
#include <cmath>
#include <iostream>
struct V3 { double x, y, z; };
const double A = 6378137.0, F = 1.0 / 298.257223563, E2 = F * (2.0 - F);
const double D2R = M_PI / 180.0;
V3 geodetic_to_ecef(double lat, double lon, double h) {
const double s = std::sin(lat * D2R), c = std::cos(lat * D2R);
const double N = A / std::sqrt(1.0 - E2 * s * s);
return { (N + h) * c * std::cos(lon * D2R),
(N + h) * c * std::sin(lon * D2R),
(N * (1.0 - E2) + h) * s };
}
V3 ecef_to_enu(V3 p, double lat0, double lon0, double h0) {
const V3 r = geodetic_to_ecef(lat0, lon0, h0);
const double dx = p.x - r.x, dy = p.y - r.y, dz = p.z - r.z;
const double sφ = std::sin(lat0 * D2R), cφ = std::cos(lat0 * D2R);
const double sλ = std::sin(lon0 * D2R), cλ = std::cos(lon0 * D2R);
return { -sλ * dx + cλ * dy,
-sφ * cλ * dx - sφ * sλ * dy + cφ * dz,
cφ * cλ * dx + cφ * sλ * dy + sφ * dz };
}
int main() {
const double lat0 = 12.9716, lon0 = 77.5946, h0 = 920.0; // site origin
// a point 0.001° north and 0.001° east of the origin
V3 enu = ecef_to_enu(geodetic_to_ecef(lat0 + 0.001, lon0 + 0.001, h0),
lat0, lon0, h0);
std::cout << "exact ENU : E = " << enu.x << " N = " << enu.y
<< " U = " << enu.z << "\n";
// the folklore shortcut: metres-per-degree on a 6371 km sphere
const double R = 6371000.0;
const double n_sph = 0.001 * D2R * R;
const double e_sph = 0.001 * D2R * R * std::cos(lat0 * D2R);
std::cout << "spherical guess: E = " << e_sph << " N = " << n_sph << "\n"
<< "shortcut error : "
<< std::hypot(enu.x - e_sph, enu.y - n_sph) * 100.0
<< " cm over ~150 m\n";
}
Expected: the exact conversion puts the point at roughly E ≈ 108.4 m, N ≈ 110.6 m (east metres shrink with cos φ; north metres reflect the ellipsoid’s meridian radius at 13°N), with U within millimetres of zero. The spherical shortcut lands tens of centimetres away over a 150 m baseline — harmless for geofencing, fatal for feeding an RTK-fixed antenna (1–2 cm!) into a factor graph, where the "measurement" would carry 30× its claimed error. The moral generalizes: RTK-grade inputs deserve WGS84-grade math.
Sanity checks: (1) verify the round trip: convert the origin itself — expect (0, 0, 0) to machine precision. (2) Push the test point to 5 km and 20 km: exact-vs-spherical error grows to metres, and even the exact tangent plane starts absorbing curvature in U — the "re-anchor beyond ~10 km" rule made visible. (3) Swap in your own site’s coordinates and keep this file; every outdoor project needs it within the first week.
7.4Fusing GNSS Into the Graph
GNSS is the sensor Phase 3 wished for: an absolute measurement, immune to drift, arriving at 1–10 Hz. The modern fusion architecture is graph-based: LIO (Phase 2) provides high-rate relative factors between poses; each GNSS epoch adds a unary factor pinning one pose toward its ENU measurement, weighted by the receiver’s reported (and status-scaled) covariance; the Phase 1 optimizer reconciles them continuously. This is exactly LIO-SAM’s shape, and the tightly-coupled end of the spectrum (GVINS-style) goes deeper — raw pseudorange/carrier factors instead of finished fixes — buying accuracy in partial-sky conditions where fewer than four satellites still contribute information.
graph: X₀ ── odom ── X₁ ── odom ── X₂ ── odom ── X₃ ⋯ (LIO, Phase 2)
│ │
GNSS z₁ GNSS z₃ (unary anchors)
r_gnss(Xᵢ) = pos(Xᵢ) − z_enu, Ω from receiver covariance × status
(fix: cm² — float: dm² — single: m²)
gate before insert: chi-squared against current marginal — a 3 m jump
against 5 cm of plausible drift is multipath, not information.
Two integration details decide whether this works in practice. Yaw alignment: ENU and the LIO map frame differ by an unknown rotation about gravity; position fixes make it observable only once the robot has moved (the trajectory’s shape in both frames is compared), so initialization needs a few metres of travel — dual-antenna heading receivers shortcut this by measuring yaw directly. The lever arm: the antenna is not at the body origin; T_body_antenna is a Phase 6 extrinsic, and forgetting it costs exactly that offset, rotated by every turn — visible as position "breathing" during rotation.
7.5Elevation Mapping & Traversability (Grid Map)
Outdoor ground is neither free nor occupied — it is terrain, and the question becomes "can these wheels handle this patch?". The workhorse representation is the 2.5D elevation map: a grid where each cell stores height (and its variance) — vastly cheaper than full 3D, expressive enough for slopes, curbs, and ruts. ETH’s Grid Map is the standard C++ library: multi-layer float grids (elevation, variance, traversability, anything), a moving robot-centric window with efficient shifting, iterators, and the robot-centric probabilistic elevation mapping stack built on it, which fuses range measurements with pose uncertainty so the map degrades honestly as odometry does. From elevation, traversability is derived per cell from three ingredients:
slope(x,y) = ‖∇h‖ via finite differences over the cell's neighborhood
step(x,y) = max height discontinuity to any neighbor (curbs!)
roughness(x,y) = local height variance after plane removal
traversable ⇔ slope < slope_max ∧ step < step_max ∧ roughness < r_max
(limits are per-platform: what a tracked robot shrugs at kills a caster)
→ the result feeds Phase 5's planners as a cost layer, exactly like a costmap
| Representation | Stores | Fails at | Use when |
|---|---|---|---|
| 2D costmap (Phase 5) | free/occupied/cost | anything with height: curbs, slopes, grass | genuinely flat sites |
| 2.5D elevation (Grid Map) | height + variance per cell | overhangs, tunnels (one z per cell) | ground robots outdoors — the default |
| Full 3D (Phase 3’s octree/TSDF) | volumetric occupancy/distance | memory & compute at km scale | overhangs matter: forests, mines, drones |
7.6The World Moves: Dynamic Objects & Map Maintenance
A parking lot mapped on Monday is a lie by Tuesday. Dynamic objects poison maps twice: ghosts (the parked truck baked into the map keeps blocking the planner after it drives away) and swiss cheese (points on moving objects scatter through free space, corrupting registration). The classical, sensor-agnostic cure is visibility-based removal: if today’s ray passes through a map point and hits something behind it, that point cannot still exist — erase it. Removert refines this with multi-resolution range-image comparison (remove aggressively, then revert what static structure was wrongly deleted); ERASOR works in per-cell height stacks in bird’s-eye view, exploiting that dynamic objects touch the ground. Upstream, modern LIO simply segments people and vehicles out of scans before integration (a Phase 8 network, run in the pipeline), and mapping layers add decay — NVBlox-style down-weighting of unobserved regions — so the map forgets gracefully. The strategic upgrade is lifelong mapping: stop treating the map as an artifact and treat it as a maintained estimate, with sessions merged, changes detected, and stale geometry demoted; the two-frame contract of Phase 3.8 already gave the runtime side of that story.
7.7Appearance: Lighting, Weather, Seasons
Everything Phase 4 said about place recognition gets stress-tested outdoors, where the same street is a different image at noon, dusk, and midnight in sleet. The sensor hierarchy of robustness: lidar geometry barely notices light (but rain attenuates, snow adds noise clusters near the sensor, fog scatters — filter by intensity and range consistency); radar shrugs at all of it, at coarse resolution; cameras suffer most and carry the most information. The camera-side toolkit: learned descriptors trained across conditions (NetVLAD/CosPlace, Phase 4.5 — trained on exactly this problem), sequence matching (SeqSLAM’s insight: a single frame may be ambiguous at night, but a 100 m sequence of frames rarely is), and multi-session experience maps — store the site’s appearance per condition (sunny-summer, wet-winter-night) and localize against the nearest experience rather than forcing one representation to span them all. The honest engineering answer for 2026 deployments remains: geometry (lidar + RTK) for the safety-critical pose, appearance for semantics and redundancy.
Setup B: elevation mapping + traversability — one evening, zero dependencies. Goal: build a 2.5D elevation map of synthetic terrain (a ramp, a curb, rough gravel), derive slope/step/roughness per cell, and print a traversability map that shows exactly why a 2D costmap lies outdoors. Save as elev.cc, compile with g++ -O2 -std=c++17 elev.cc -o elev:
// elev.cc — a 2.5D elevation map with slope/step/roughness traversability.
#include <algorithm>
#include <cmath>
#include <iostream>
#include <random>
#include <vector>
const int W = 60, H = 24; // 6 m × 2.4 m at 10 cm cells
double terrain(double x, double y) { // ground truth height field
double z = 0.0;
if (x > 2.0 && x < 3.2) z += (x - 2.0) * 0.25; // 14° ramp up
if (x >= 3.2) z += 0.30; // plateau
if (x > 4.4 && y > 1.2) z += 0.18; // a curb (step!)
if (x > 0.6 && x < 1.6 && y < 0.9) // gravel patch
z += 0.03 * std::sin(37.0 * x) * std::sin(41.0 * y);
return z;
}
int main() {
std::mt19937 rng(9);
std::normal_distribution<double> noise(0.0, 0.005); // 5 mm sensor noise
// "scan" the terrain: 40k random hits, keep per-cell mean height
std::vector<double> sum(W * H, 0.0); std::vector<int> cnt(W * H, 0);
std::uniform_real_distribution<double> ux(0.0, 6.0), uy(0.0, 2.4);
for (int i = 0; i < 40000; ++i) {
const double x = ux(rng), y = uy(rng);
const int c = std::min(H - 1, int(y * 10)) * W + std::min(W - 1, int(x * 10));
sum[c] += terrain(x, y) + noise(rng); cnt[c]++;
}
std::vector<double> z(W * H, 0.0);
for (int c = 0; c < W * H; ++c) if (cnt[c]) z[c] = sum[c] / cnt[c];
// traversability: slope < 20°, step < 12 cm (a modest wheeled base)
std::cout << "traversability ('.' go, '#' no-go):\n";
for (int gy = H - 1; gy >= 0; --gy) {
std::cout << " ";
for (int gx = 0; gx < W; ++gx) {
double step = 0.0, sx = 0.0, sy = 0.0;
for (int dy = -1; dy <= 1; ++dy) for (int dx = -1; dx <= 1; ++dx) {
const int nx = gx + dx, ny = gy + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
step = std::max(step, std::abs(z[ny * W + nx] - z[gy * W + gx]));
}
if (gx > 0 && gx < W - 1) sx = (z[gy * W + gx + 1] - z[gy * W + gx - 1]) / 0.2;
if (gy > 0 && gy < H - 1) sy = (z[(gy + 1) * W + gx] - z[(gy - 1) * W + gx]) / 0.2;
const bool ok = std::hypot(sx, sy) < std::tan(20.0 * M_PI / 180.0)
&& step < 0.12;
std::cout << (ok ? '.' : '#');
}
std::cout << "\n";
}
}
Expected: an ASCII field that reads like the terrain — the gravel patch passes (rough but within limits), the 14° ramp passes (steep but under 20°), and the curb prints as a hard # wall across its edge while the plateau behind it is fine. Now the punchline: a 2D costmap of this scene would have shown nothing — every cell is "ground" — and a naive 3D occupancy view would have marked the whole ramp occupied. The 2.5D layer is precisely the representation that can say "passable, from the low side, at reduced speed".
Sanity checks: (1) drop step_max to 0.05 — the ramp’s discretization starts failing cells: resolution and thresholds interact, tune them together. (2) Raise sensor noise to 3 cm and watch phantom no-go speckle appear — this is why real elevation maps carry per-cell variance and fuse before classifying (the Grid Map stack’s whole design). (3) Approach the curb region from the other side in your head: traversability is directional for real robots — which is why production layers store slope vectors, not magnitudes, for Phase 5 to consume.
7.8Reading List
LIO-SAM: Tightly-Coupled Lidar Inertial Odometry via Smoothing and MappingThe reference architecture for Fig 7.2: LIO factors plus GNSS anchors in one GTSAM graph — readable code, deployed everywhere. GVINS: Tightly Coupled GNSS-Visual-Inertial FusionThe tightly-coupled end: raw pseudorange and Doppler factors, so three visible satellites still help instead of "no fix, no data". Springer Handbook of Global Navigation Satellite SystemsThe GNSS reference: error budgets, carrier-phase processing, ambiguity resolution — where section 7.2’s summaries become rigorous. RTKLIB: Open-Source GNSS PositioningThe standalone RTK engine: NTRIP client, ambiguity resolution, post-processing — the tool that demystifies the fix/float lights. A Universal Grid Map LibraryThe multi-layer grid, robot-centric shifting, and iterator design that made 2.5D mapping a library instead of a per-project rewrite. Probabilistic Terrain Mapping for Mobile Robots with Uncertain LocalizationElevation mapping that admits pose uncertainty — heights with variances, fused honestly. Setup B’s grown-up sibling. Remove, then Revert: Static Point Cloud Map Construction using Multiresolution Range ImagesVisibility-based dynamic removal with an undo pass — the pragmatic recipe for de-ghosting survey maps. ERASOR: Egocentric Ratio of Pseudo Occupancy-Based Dynamic Object RemovalGround-touching objects betray themselves in per-cell height stacks — fast, lidar-native de-ghosting. SeqSLAM: Visual Route-Based Navigation for Sunny Summer Days and Stormy Winter NightsThe title is the thesis: single frames lie across seasons, sequences don’t. The paper that reframed appearance-invariant localization.7.9Interview Questions
Why must GNSS coordinates be converted to a local ENU frame before fusion — why not work in latitude/longitude?
Latitude and longitude are angles on an ellipsoid, not distances: a degree of longitude is ~111 km at the equator and shrinks with cos φ, so Euclidean math on degrees is dimensionally wrong and anisotropically scaled. Filters, factors, and planners all assume a metric vector space with meaningful Gaussians. The exact route is geodetic → ECEF (using the WGS84 ellipsoid’s N(φ)) → ENU about one fixed site anchor, giving honest metres in a right-handed local frame. The spherical-Earth shortcut misplaces points by tens of centimetres over hundreds of metres — 30× an RTK fix’s stated error — and the tangent plane itself should be re-anchored beyond ~10 km.
RTK fix vs float — what is actually different, and how should fusion treat them?
RTK works on carrier phase, where the unknown per-satellite integer number of wavelengths (the ambiguity) must be resolved. "Fix" means the integers were pinned confidently — accuracy 1–2 cm, trustworthy. "Float" means they remain real-valued estimates — decimetre-level, and crucially, capable of coherent drift as the ambiguity estimates wander, which violates the white-noise assumption of a naive fusion. Treat status as a covariance multiplier at minimum (cm² / dm² / m² for fix/float/single), gate float solutions harder, and never let a "fixed" flag bypass the chi-squared gate — urban multipath produces confidently wrong fixes, and the gate is what catches them.
How does GNSS-LIO graph fusion handle an urban canyon, and what is the failure mode if it doesn’t?
In the canyon, multipath produces GNSS positions that are metres wrong with optimistic covariances. The graph defense is layered: scale covariance by fix status and satellite geometry (DOP), chi-squared gate each epoch against the current marginal (LIO drift over one epoch is centimetres, so a 3 m innovation is a lie, not information), optionally wrap surviving GNSS factors in robust kernels, and let LIO carry the trajectory unaided through the outage — that is exactly what it is for. Without gating, one accepted multipath fix yanks the whole recent trajectory sideways (the optimizer believes it), the map shears, and the planner responds to a world that jumped — the outdoor twin of Phase 3’s false loop closure.
Why is the ENU-to-map yaw alignment a special problem, and how do systems solve it?
GNSS measures position only, so a single fix says nothing about which way the LIO map’s x-axis points relative to East — the rotation about gravity between the two frames is unobservable until the trajectory has shape: after some travel, matching the path’s geometry in both frames pins the yaw. Consequences: initialization requires motion (systems drive a few metres before trusting alignment), degenerate straight-line-only paths leave residual ambiguity, and the alignment should be estimated as a state (it can be refined) rather than hard-coded. The hardware shortcut is a dual-antenna receiver, which measures heading directly from the baseline between antennas and removes the whole bootstrapping dance.
2D costmap, 2.5D elevation map, full 3D map — how do you choose for an outdoor ground robot?
Choose by the cheapest representation that answers the platform’s traversability question. A 2D costmap represents only free/occupied — it cannot express a curb (lethal to casters, trivial to tracks) or a 15° slope, so outdoors it lies in both directions. A 2.5D elevation map stores height and variance per cell: slopes, steps, and roughness become computable, memory stays near-2D, and it feeds Phase 5 as cost layers — the correct default for wheels and legs on ground. Full 3D (octree/TSDF) is required only when one-height-per-cell breaks: overhangs, tree canopies, tunnels, multi-level structures — paid for in memory and update cost at kilometre scale. Many stacks run 2.5D for planning plus local 3D for collision checking the body.
Your survey-grade map keeps a truck that has since driven away. Compare the removal strategies.
Visibility/raycast methods (the classical cure): any ray passing through a map point proves it absent — sensor-agnostic and principled, but expensive at scale and blind where you never re-observed; Removert’s multi-resolution compare-then-revert makes it practical and un-deletes wrongly removed statics. ERASOR compares per-cell height stacks in bird’s-eye view, exploiting that dynamics touch the ground — fast, lidar-native, tuned for vehicles/pedestrians. Segmentation-first (run a detector, never integrate people/cars) prevents rather than cures, at the cost of a learned model and its misses. Map decay (NVBlox-style) forgets unobserved regions gracefully — robust, but forgets valid unvisited structure too. Production answer: segment upstream, decay in the background, and run a Removert/ERASOR pass on survey maps before release.
What actually happens to a lidar in rain, snow, and fog — and what do you do about it?
Rain: mostly attenuation and occasional early returns off dense drops — range shortens, noise rises modestly; heavy rain can halve effective range. Snow: bright, close-in clutter — flakes return strongly near the sensor, producing noise clusters that corrupt registration; filter by the signature (close range + low intensity + spatial isolation), or use dual-return lidars where the second return sees past the flake. Fog: scattering — dramatic range loss and a haze of false returns; below some visibility, lidar is simply degraded and the stack must know it. Systemically: monitor return statistics as a weather-health signal, widen registration gating when quality drops, lean harder on RTK + IMU + wheels (their weather immunity is why they’re in the stack), and slow down — degraded perception with unchanged speed is a decision, and the wrong one.
Design localization for a campus delivery robot: buildings, tree cover, seasons, snow.
Belt and suspenders, weighted by conditions. Base: LIO (Phase 2) for continuous pose; RTK-GNSS via NTRIP fused as gated unary factors — centimetric in open sky, gracefully absent under trees and in urban canyons between buildings; dual-antenna heading to kill yaw bootstrapping. Map side: a lidar map for scan-matching localization (Phase 4) weighted toward permanent geometry (facades, curbs) with seasonal re-surveys scheduled — snowbanks reshape geometry, so match-fitness per region is monitored as map health. Appearance: CosPlace-style retrieval plus sequence matching as a redundant coarse localizer for lidar-degenerate open fields, trained/validated across the site’s seasons. Fusion: one factor graph, chi-squared gates on every absolute input, covariance inflation tied to weather-health metrics, and an explicit degraded mode — reduced speed, tighter geofence — the robot enters when any two of GNSS, lidar match fitness, and visual retrieval disagree. The design principle: every source is allowed to fail, and no single failure changes the pose by more than its gate allows.