Mathematical Foundations & Optimization
Poses and how to compose them, rotations and how to differentiate them, and the one optimization loop that calibration, SLAM, and planning all secretly are. Nothing in the next seven phases is new math — it is this math, re-applied.
1.1Frames and Rigid Transformations
Every quantity a robot cares about — a lidar return, a wheel tick, a goal, gravity — is a set of numbers measured with respect to something. That something is a coordinate frame: an origin plus axes. A mobile robot drags a small zoo of them around: a fixed world (or map) frame, a base frame bolted to the chassis, and one frame per sensor. The same physical point has different coordinates in each, and the entire discipline of not confusing them is one object: the rigid transformation.
A rigid transformation is a rotation R plus a translation t. Applied to a point it rotates first, then translates. Packing both into a single matrix using homogeneous coordinates turns "rotate then translate" into one multiplication, which is what makes chains of frames manageable:
p_w = R_wb · p_b + t_wb rotate, then translate
homogeneous form: p̃ = [p; 1]
T_wb = [ R_wb t_wb ] p̃_w = T_wb · p̃_b
[ 0 1 ]
SE(2), planar pose, 3 DoF (x, y, θ): SE(3), 3D pose, 6 DoF:
T = [ cos θ −sin θ x ] T = [ R₃ₓ₃ t₃ₓ₁ ]
[ sin θ cos θ y ] [ 0 1 ]
[ 0 0 1 ]
composition: T_wl = T_wb · T_bl adjacent subscripts cancel
inverse: T_wb⁻¹ = [ Rᵀ −Rᵀt ] = T_bw
[ 0 1 ]
The subscript convention carries more weight than it looks. Read T_wb as "the pose of frame b expressed in frame w", or equivalently "the matrix that maps coordinates from b into w". Written that way, correctness becomes typography: in T_wb · T_bl the two b’s touch and cancel, leaving T_wl. If the inner subscripts don’t match, the expression is wrong before you evaluate a single number. The diagram below is the whole idea:
- Frame
- An origin plus orthogonal axes. Coordinates mean nothing until you say which frame they are in.
- SE(2), SE(3)
- The special Euclidean groups: all rigid transformations of the plane (3 DoF) and of 3D space (6 DoF). "Group" mostly means composable, invertible, closed under both.
- Extrinsics
- The fixed transform between two sensors or a sensor and the body, like T_bl above. Measured once by calibration, then trusted by everything.
- Homogeneous coordinates
- Appending a 1 to a point so rotation-plus-translation becomes a single matrix product, and chains of transforms become chains of multiplications.
T_base_lidar, never lidar_tf), make the subscript-cancellation check a habit, and test with an asymmetric configuration — a sensor mounted dead-center on a robot facing forward makes half of all frame bugs invisible until the day the hardware changes.1.2Rotations Are the Troublemakers
Translation is a vector: you can add translations, average them, scale them, and put Gaussians on them. Rotation looks like it should behave the same way and does not, and the gap between those two sentences is why this chapter needs Lie theory at all.
Three facts break the vector illusion. First, rotations do not commute: roll-then-pitch is not pitch-then-roll, while addition never cares about order. Second, angles wrap around: the average of headings +179° and −179° is ±180°, but the arithmetic mean says 0° — a robot that averages raw angles will eventually turn to face exactly backwards. Third, the popular three-number parametrization, Euler angles, has a singularity: at pitch ±90° two axes align, a degree of freedom vanishes, and Jacobians divide by zero. That is gimbal lock, and it is a property of the parametrization, not of physics.
The set of all 3D rotations has a name and a definition worth memorizing:
SO(3) = { R ∈ ℝ³ˣ³ : RᵀR = I, det R = +1 }
3 degrees of freedom, stored in 9 numbers with 6 constraints —
or in a unit quaternion: 4 numbers, 1 constraint, and q ≡ −q (double cover)
| Representation | Numbers | Constraint | Pain point |
|---|---|---|---|
| Euler angles (roll, pitch, yaw) | 3 | none | Gimbal lock at pitch ±90°, wrap-around, a dozen axis-order conventions |
| Unit quaternion | 4 | unit norm | Double cover (q and −q are the same rotation); drifts off the sphere under naive arithmetic |
| Rotation matrix | 9 | orthogonal, det = 1 | Six constraints to maintain; numerical drift needs re-orthogonalization |
| Tangent vector (axis-angle, next section) | 3 | none locally | Only valid as a local chart around a reference rotation |
Every row of that table is a bad place to do calculus. Euler angles are singular, quaternions and matrices are constrained, and an optimizer that takes an unconstrained step off the quaternion sphere or out of orthogonality produces a "rotation" that isn’t one. The resolution is not a better parametrization — it is to stop pretending rotations live in a vector space at all.
1.3Lie Theory Without Tears: so(3) and se(3)
SO(3) is a manifold: a curved 3-dimensional surface living inside the 9-dimensional space of matrices, the way a sphere is a curved 2D surface inside 3D. You cannot add points on a curved surface. But every manifold is locally flat: zoom in far enough at any point and it looks like a plain vector space. That local flat approximation is the tangent space, and for SO(3) it is an honest ℝ³ — three unconstrained numbers where addition, Gaussians, Jacobians, and least squares all work exactly as they should.
The tangent space of SO(3) at the identity is called so(3), and its elements have two equivalent forms: a plain vector ω ∈ ℝ³ (rotation axis times angle), or a skew-symmetric matrix. The hat operator converts vector to matrix, vee (ᵛ) converts back, and the exponential map — an honest matrix exponential, with a famous closed form — wraps a tangent vector onto the manifold:
hat: [ 0 −ω₃ ω₂ ]
ω = (ω₁, ω₂, ω₃)ᵀ → [ ω₃ 0 −ω₁ ] = ω^ note: ω^ v = ω × v
[ −ω₂ ω₁ 0 ]
exponential map (Rodrigues' formula), θ = ‖ω‖:
R = exp(ω^) = I + (sin θ / θ) · ω^ + ((1 − cos θ) / θ²) · (ω^)²
log map (the inverse):
θ = arccos( (tr R − 1) / 2 ) ω = (θ / (2 sin θ)) · (R − Rᵀ)ᵛ
Poses get the same treatment. SE(3) is a 6-dimensional manifold; its tangent space se(3) holds twists ξ = (ρ, ω) — three numbers of translation-like velocity and three of rotation. The exponential map couples them through a matrix V (rotation makes the translation curve, which is why a robot commanding constant (v, ω) drives an arc, not a line — the exact fact wheel odometry in Phase 2 integrates):
ξ = (ρ, ω) ∈ ℝ⁶ ≅ se(3)
exp(ξ^) = [ exp(ω^) V·ρ ] V = I + ((1 − cos θ)/θ²) ω^
[ 0 1 ] + ((θ − sin θ)/θ³) (ω^)²
boxplus / boxminus — the only two operators estimation ever needs:
x ⊞ δ = x · exp(δ^) perturb pose x by tangent step δ
y ⊟ x = log(x⁻¹ · y)ᵛ the tangent step carrying x to y
Two conventions exist for ⊞: multiply the perturbation on the right (a step in the body frame, used above) or on the left (a step in the world frame). Both are fine; mixing them is not, because Jacobians derived in one convention are wrong in the other. The adjoint matrix Adj_T is the bookkeeping device that converts tangent vectors between the two sides: T · exp(δ^) = exp((Adj_T δ)^) · T. You mostly need to know it exists and that your library documents its choice.
The cheat sheet for the four groups this book actually uses:
| Group | Represents | DoF | Tangent space | exp in practice |
|---|---|---|---|---|
| SO(2) | planar rotation | 1 | θ ∈ ℝ | trivial: wrap the angle |
| SE(2) | planar pose | 3 | (ρ₁, ρ₂, θ) ∈ ℝ³ | closed form with a 2×2 V matrix — exactly the unicycle motion model |
| SO(3) | 3D rotation | 3 | ω ∈ ℝ³ | Rodrigues’ formula |
| SE(3) | 3D pose | 6 | ξ = (ρ, ω) ∈ ℝ⁶ | Rodrigues plus the V matrix |
- Manifold
- A curved space that is locally flat. Sphere: 2D manifold. SO(3): 3D manifold. SE(3): 6D manifold.
- Tangent space / Lie algebra
- The flat vector space that best approximates the manifold at a point. At the identity it gets a special name: so(3), se(3).
- hat (^) / vee (ᵛ)
- Convert between the vector form of a tangent element and its matrix form. ω^ is also the cross-product matrix.
- exp / log
- Wrap a tangent vector onto the manifold; unwrap a manifold displacement into a tangent vector. Closed-form for all groups in this book.
- Adjoint
- The linear map that carries tangent vectors between right- and left-perturbation conventions, or between frames.
1.4Sophus and manif: Lie Groups You Can Compile
You should implement Rodrigues’ formula once in your life, then never again. Two header-only C++ libraries own this niche, and this book uses both. Sophus (Strasdat) is the veteran: Eigen-based SO(2)/SE(2)/SO(3)/SE(3)/Sim(3), battle-tested inside a large share of the SLAM systems in Phases 2 and 3, with a ready-made Ceres manifold adapter we will use in Setup B. manif (Deray & Solà) is the younger sibling written to accompany the micro Lie theory paper: same groups plus SE_2(3) (the extended pose used by modern IMU filters), and its defining feature is that every operation can return its analytic Jacobians — exactly the matrices an EKF or a factor graph needs, with no autodiff and no finite differences.
The core of Sophus in fifteen lines:
#include <sophus/se3.hpp>
// a pose: 30° about z, translated (1, 2, 0)
Sophus::SE3d T_wb(Sophus::SO3d::rotZ(M_PI / 6.0),
Eigen::Vector3d(1.0, 2.0, 0.0));
// log: pose → twist ξ = (ρ, ω), a plain 6-vector you can put a Gaussian on
Eigen::Matrix<double, 6, 1> xi = T_wb.log();
// exp: twist → pose. Round trip is identity up to machine precision
Sophus::SE3d T_rt = Sophus::SE3d::exp(xi);
// boxplus: perturb by a small tangent step (right convention)
Eigen::Matrix<double, 6, 1> delta;
delta << 0.01, 0, 0, 0, 0, 0.002; // 1 cm forward, 2 mrad yaw
Sophus::SE3d T_pert = T_wb * Sophus::SE3d::exp(delta);
// frames compose and invert like the math says
Eigen::Vector3d p_w = T_wb * Eigen::Vector3d(0.5, 0.0, 0.0);
And the manif habit that makes Phase 2 pleasant — Jacobians on demand from every operator:
#include <manif/manif.h>
manif::SE2d X(1.0, 2.0, M_PI / 6.0); // planar pose: x, y, θ
manif::SE2Tangentd w;
w << 0.05, 0.0, 0.01; // a small twist
manif::SE2d::Jacobian J_x, J_w; // 3×3 analytic Jacobians
manif::SE2d X2 = X.plus(w, J_x, J_w); // X2 = X ⊞ w, ∂X2/∂X, ∂X2/∂w
// every EKF predict step in Phase 2 is literally this call:
// state ⊞ (velocity · dt), with J_x propagating the covariance
| Library | Groups | Jacobians | Pick it when |
|---|---|---|---|
| Sophus | SO/SE(2), SO/SE(3), Sim(3) | Bring your own (or autodiff via its Ceres adapter) | You are feeding Ceres, or matching the conventions of existing SLAM code |
| manif | SO/SE(2), SO/SE(3), SE_2(3), Sim(3) | Analytic, from every operation, following Solà et al. conventions | You are writing filters by hand and want exact covariance propagation |
Setup A: frames, exp/log, and the angle-averaging trap — twenty minutes. Goal: compile one file that chains two transforms, round-trips exp/log to machine precision, and shows naive angle averaging pointing a robot exactly backwards.
Prereqs: any Linux with a C++17 compiler. Sophus is header-only; the cmake flag avoids its optional fmt dependency:
sudo apt install build-essential cmake libeigen3-dev
git clone https://github.com/strasdat/Sophus.git
cmake -S Sophus -B Sophus/build -DSOPHUS_USE_BASIC_LOGGING=ON
sudo cmake --install Sophus/build
Save as hello_se3.cc, compile with g++ -std=c++17 -I/usr/include/eigen3 hello_se3.cc -o hello_se3:
// hello_se3.cc — poses, exp/log, and why you never average angles.
#include <sophus/se3.hpp>
#include <iostream>
int main() {
// 1. frame chain: lidar → base → world (Fig 1.1, in code)
const Sophus::SE3d T_wb(Sophus::SO3d::rotZ(M_PI / 6.0), // robot at (1,2), yaw 30°
Eigen::Vector3d(1.0, 2.0, 0.0));
const Sophus::SE3d T_bl(Sophus::SO3d(), // lidar 20 cm fwd, 35 cm up
Eigen::Vector3d(0.20, 0.0, 0.35));
const Eigen::Vector3d p_l(4.0, 0.0, 0.0); // return 4 m ahead of lidar
std::cout << "p_w = " << (T_wb * T_bl * p_l).transpose() << "\n";
// 2. exp/log round trip
const auto xi = T_wb.log();
const double err = (Sophus::SE3d::exp(xi).inverse() * T_wb).log().norm();
std::cout << "round-trip error = " << err << "\n";
// 3. the trap: average headings +170° and −170°
const Sophus::SO3d Ra = Sophus::SO3d::rotZ( 170.0 * M_PI / 180.0);
const Sophus::SO3d Rb = Sophus::SO3d::rotZ(-170.0 * M_PI / 180.0);
const double naive_deg = (170.0 + (-170.0)) / 2.0; // arithmetic mean
const Sophus::SO3d mid = // geodesic midpoint:
Ra * Sophus::SO3d::exp(0.5 * (Ra.inverse() * Rb).log()); // walk halfway on SO(3)
std::cout << "naive mean yaw = " << naive_deg << " deg\n";
std::cout << "manifold mean yaw = " << mid.log()(2) * 180.0 / M_PI << " deg\n";
}
Expected: p_w ≈ (4.64, 4.10, 0.35) — work it by hand once: p_b = (4.2, 0, 0.35), rotate by 30°, add (1, 2, 0). Round-trip error at 1e−16. And the finale: naive mean yaw 0°, manifold mean ±180°. Both headings point almost backwards; the arithmetic mean points dead ahead. Any code that averages, interpolates, or low-pass-filters raw angles contains this bug at some density.
Sanity checks: (1) swap the composition to T_bl * T_wb — the answer changes completely, because subscripts no longer cancel; that is the frame-bug experience in miniature. (2) Print T_wb.inverse() * T_wb and confirm identity. (3) Change the two headings to +10°/−10°: now naive and manifold means agree — the trap only springs near the wrap, which is why it survives testing and dies in the field.
1.5The Bayes Filter: The Master Recipe
Everything above describes a robot’s state; nothing yet estimates it. Estimation starts from an honest admission: sensors are noisy, motion is sloppy, so the robot’s knowledge is not a pose but a probability distribution over poses — a belief. Two models describe how the world moves and how it is sensed, each with its noise stated explicitly:
x_t = f(x_{t−1}, u_t) + w_t motion model, w_t ~ N(0, Q)
z_t = h(x_t) + v_t measurement model, v_t ~ N(0, R)
bel(x_t) = p(x_t | z_{1:t}, u_{1:t}) the belief we maintain
predict: bel⁻(x_t) = ∫ p(x_t | x_{t−1}, u_t) · bel(x_{t−1}) dx_{t−1}
correct: bel(x_t) = η · p(z_t | x_t) · bel⁻(x_t)
The two lines at the bottom are the Bayes filter, and they are a heartbeat. Predict pushes the belief through the motion model — a convolution that smears probability mass, so uncertainty grows. Correct multiplies by the measurement likelihood — a reweighting that concentrates mass where the sensor agrees, so uncertainty shrinks. Widen, narrow, widen, narrow, at sensor rate, forever. Its legality rests on the Markov assumption: the state summarizes all history, so the past influences the future only through xt−1.
The integral is intractable over a continuous 6-DoF state, so every practical filter is a compromise about the belief’s shape. Constrain it to a Gaussian and linearize the models: the EKF. Sample the nonlinearity at sigma points: the UKF. Represent the belief with a swarm of weighted samples: the particle filter that powers global localization. Those are Phase 2 and Phase 4’s machinery; what this phase supplies is the recipe they all instantiate, plus the one Gaussian fact that explains every fusion result you will ever compute:
fusing N(μ₁, σ₁²) with N(μ₂, σ₂²):
σ² = (1/σ₁² + 1/σ₂²)⁻¹ precisions ADD — fusion never hurts
μ = σ² · (μ₁/σ₁² + μ₂/σ₂²) precision-weighted mean
example: N(3.0, 4.0) × N(5.0, 1.0) → N(4.6, 0.8)
1.6Nonlinear Least Squares and Gauss-Newton
The Bayes filter runs forward in time and never revisits the past. The other pillar of this phase solves for everything at once: given a pile of measurements, find the states that explain them best. Under Gaussian noise, "best" stops being a matter of taste — maximizing the likelihood of the measurements is exactly minimizing a sum of squared, covariance-weighted residuals:
residual — what a measurement disagrees with a candidate state x by:
rᵢ(x) = zᵢ − hᵢ(x)
Gaussian noise ⇒ maximum likelihood = least squares:
x* = argmax Πᵢ exp(−½ rᵢᵀ Σᵢ⁻¹ rᵢ)
= argmin Σᵢ rᵢ(x)ᵀ Σᵢ⁻¹ rᵢ(x)
the covariance Σᵢ⁻¹ is the weight: confident measurements pull harder
The residuals are nonlinear in x (rotations guarantee that), so no closed-form minimum exists. Gauss-Newton is the classical fix: linearize each residual around the current guess, solve the resulting linear least-squares problem exactly, step, repeat:
linearize: rᵢ(x ⊞ δ) ≈ rᵢ(x) + Jᵢ δ Jᵢ = ∂rᵢ/∂δ at δ = 0
stack all residuals and solve the normal equations:
(Jᵀ Σ⁻¹ J) δ = −Jᵀ Σ⁻¹ r H = JᵀΣ⁻¹J is the Gauss-Newton Hessian
update: x ← x ⊞ δ, repeat until ‖δ‖ is tiny
Two remarks the compact notation hides. First, H = JᵀΣ⁻¹J is an approximation of the true Hessian — the dropped term involves second derivatives of the residuals scaled by the residuals themselves, so the approximation is excellent precisely when residuals are small (near the solution) or models are mildly curved. You get near-second-order convergence at first-order cost, which is the whole appeal. Second, H is sparse in every robotics problem that matters: each measurement touches only a couple of variables (one pose and one landmark; two consecutive poses), so J has a few nonzero blocks per row and H is nearly all zeros. Sparse Cholesky factorization exploits that ruthlessly — it is the only reason optimizing ten thousand poses at interactive rates is possible, and Phase 3 (pose graphs) and Phase 6 (calibration) both bank on it.
Gauss-Newton’s temper is the reason it never ships alone: it trusts its linearization completely. Near the minimum, where the quadratic model is honest, it converges in a handful of iterations. Far from the minimum, the model is fiction, the full step overshoots into worse territory, and the iteration can diverge outright.
1.7Levenberg-Marquardt and the Art of Not Overshooting
Levenberg-Marquardt is Gauss-Newton with an adjustable amount of humility. Add a damping term λ to the diagonal of H, and the same linear solve smoothly interpolates between two algorithms:
(H + λ · diag(H)) δ = −g g = Jᵀ Σ⁻¹ r (Marquardt's scaling;
Levenberg's original used λI)
λ → 0 : the step is pure Gauss-Newton — bold, quadratic model trusted
λ → large : δ ≈ −g / λ — a short, safe gradient-descent step
policy after each step: cost ↓ → accept, shrink λ (≈ ÷3)
cost ↑ → reject, grow λ (≈ ×10), re-solve
The modern reading of λ is a trust region: the radius over which the quadratic model is believed. Good steps expand the region; bad steps shrink it. The algorithm is self-tuning, which is why LM has been the default engine of bundle adjustment, calibration, and pose-graph optimization for sixty years — and it is what actually runs when Phase 3’s SLAM backend "optimizes the graph".
| Method | Step | Convergence near x* | Far from x* | Verdict |
|---|---|---|---|---|
| Gradient descent | δ = −α g | Slow (linear), zig-zags in narrow valleys | Safe | Never the right tool alone here |
| Gauss-Newton | H δ = −g | Fast (near-quadratic) | Can diverge | Fine with an excellent initial guess |
| Levenberg-Marquardt | (H + λ·diag(H)) δ = −g | Fast — λ → 0 recovers GN | Safe — λ grows, step shrinks | The default, everywhere in this book |
1.8Optimizing on the Manifold: The Whole Phase in One Loop
Now snap the two halves of the chapter together. States live on manifolds (1.1–1.3); estimators minimize weighted squared residuals (1.6–1.7). The composite algorithm — nonlinear least squares with manifold updates — is four lines, and it is arguably the most-executed loop in robotics:
given residuals rᵢ, a state x on a manifold, weights Σᵢ⁻¹:
repeat:
Jᵢ = ∂ rᵢ(x ⊞ δ) / ∂δ evaluated at δ = 0 (tangent-space Jacobian)
solve (Jᵀ Σ⁻¹ J + λ·diag) δ = −Jᵀ Σ⁻¹ r
x ← x ⊞ δ (retract onto manifold)
until ‖δ‖ small
Rename the residuals and you have named half this book. Reprojection errors of 3D points into cameras: bundle adjustment. Relative-pose disagreements between odometry and loop closures: pose-graph optimization (Phase 3). Point-to-plane distances between two lidar scans: the inner loop of ICP (Phase 2). Checkerboard corners against a projection model: camera calibration (Phase 6). Tracking error against a dynamics model over a horizon: the QP heart of MPC (Phase 5). Different residuals, same loop.
1.9Ceres, g2o, GTSAM: The Three Back-Ends
Three mature C++ libraries implement section 1.8 so you never hand-roll it. They differ in abstraction, not in math:
| Library | Native abstraction | Derivatives | Where you will meet it |
|---|---|---|---|
| Ceres Solver | Generic cost functions over parameter blocks; manifolds via a Manifold plug-in | Superb automatic differentiation (templated functors) | Cartographer, VINS-Fusion, most calibration tools (Phase 6) |
| g2o | Graph of vertices (states) and edges (measurements) | Hand-derived (analytic) Jacobians, mostly | ORB-SLAM’s backend, classic pose-graph SLAM (Phase 3) |
| GTSAM | Factor graphs, with incremental solving (iSAM2) built in | Analytic, with autodiff options | LIO/VIO backends, GNSS fusion (Phases 2, 7) |
A rule of thumb that survives contact with real projects: Ceres when your problem is "minimize this cost I just invented" (calibration, alignment, fitting); g2o when it is a textbook pose graph and you want the classic machinery; GTSAM when factors arrive continuously and you need incremental updates rather than batch re-solves. All three accept robust loss functions (Huber, Cauchy) that tame outlier measurements — a theme Phase 3 returns to when loop closures lie.
Setup B: SE(3) alignment with Ceres + Sophus — one evening. Goal: recover the rigid transform between two point clouds with known correspondences by running LM over the SE(3) tangent space, then break it two instructive ways. This is the exact shape of problems you will meet as ICP (Phase 2), extrinsic calibration (Phase 6), and map merging (Phase 3).
Prereqs: Setup A’s Sophus install, plus Ceres:
sudo apt install libceres-dev # Ubuntu 22.04+/Debian: Ceres 2.x
CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(align_se3 CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(Ceres REQUIRED)
find_package(Sophus REQUIRED)
add_executable(align_se3 align_se3.cc)
target_link_libraries(align_se3 Ceres::ceres Sophus::Sophus)
align_se3.cc:
// align_se3.cc — recover T_ba between two clouds by LM on the SE(3) manifold.
#include <ceres/ceres.h>
#include <sophus/se3.hpp>
#include <sophus/ceres_manifold.hpp>
#include <iostream>
#include <random>
#include <vector>
struct PointPairResidual {
PointPairResidual(const Eigen::Vector3d& p_a, const Eigen::Vector3d& p_b)
: p_a_(p_a), p_b_(p_b) {}
template <typename T>
bool operator()(const T* const pose, T* residual) const {
const Eigen::Map<const Sophus::SE3<T>> T_ba(pose);
Eigen::Map<Eigen::Matrix<T, 3, 1>> r(residual);
r = T_ba * p_a_.template cast<T>() - p_b_.template cast<T>();
return true;
}
const Eigen::Vector3d p_a_, p_b_;
};
int main() {
std::mt19937 rng(42);
std::normal_distribution<double> noise(0.0, 0.01); // 1 cm sensor noise
std::uniform_real_distribution<double> coord(-2.0, 2.0);
// ground truth: 20° about z, translated (0.5, −0.3, 0.2)
const Sophus::SE3d T_ba_true(Sophus::SO3d::rotZ(20.0 * M_PI / 180.0),
Eigen::Vector3d(0.5, -0.3, 0.2));
std::vector<Eigen::Vector3d> pts_a, pts_b;
for (int i = 0; i < 200; ++i) {
Eigen::Vector3d p(coord(rng), coord(rng), coord(rng));
pts_a.push_back(p);
pts_b.push_back(T_ba_true * p +
Eigen::Vector3d(noise(rng), noise(rng), noise(rng)));
}
Sophus::SE3d T_ba; // identity — a poor initial guess
ceres::Problem problem;
problem.AddParameterBlock(T_ba.data(), Sophus::SE3d::num_parameters,
new Sophus::Manifold<Sophus::SE3>());
for (int i = 0; i < 200; ++i) {
problem.AddResidualBlock(
new ceres::AutoDiffCostFunction<PointPairResidual, 3,
Sophus::SE3d::num_parameters>(
new PointPairResidual(pts_a[i], pts_b[i])),
nullptr, // experiment 2: new ceres::HuberLoss(0.05)
T_ba.data());
}
ceres::Solver::Options opts;
opts.linear_solver_type = ceres::DENSE_QR;
opts.minimizer_progress_to_stdout = true; // watch λ (tr_radius) breathe
ceres::Solver::Summary summary;
ceres::Solve(opts, &problem, &summary);
const Sophus::SE3d err = T_ba_true.inverse() * T_ba;
std::cout << summary.BriefReport() << "\n"
<< "translation error: " << err.translation().norm() << " m\n"
<< "rotation error : "
<< err.so3().log().norm() * 180.0 / M_PI << " deg\n";
}
Build and run: cmake -B build && cmake --build build && ./build/align_se3. Expected: the LM trace shows cost collapsing from order 10¹–10² to order 10⁻² in five or so iterations, with tr_ratio near 1 (the quadratic model is being honest) and the trust radius expanding. Final errors: translation around a millimetre, rotation a few hundredths of a degree — the 1 cm noise averaged down by two hundred correspondences.
Now break it, deliberately. Experiment 1 — lose the manifold: replace the AddParameterBlock call with a plain 7-parameter block (no Sophus::Manifold). Ceres now steps the quaternion like four free numbers: it drifts off unit norm, and the "rotation" it converges to is not a rotation. This is section 1.3’s entire argument, rendered as a wrong answer. Experiment 2 — outliers: corrupt 20% of pts_b with a 0.5 m offset and rerun; the estimate is visibly dragged. Swap the nullptr loss for new ceres::HuberLoss(0.05) and the errors return to near-clean levels — the quadratic cost stops escalating past 5 cm, so gross outliers lose their leverage. Phase 3 uses the same trick on lying loop closures.
Sanity checks: (1) Sophus stores SE3 parameters quaternion-first — never hand-index data(). (2) If find_package(Sophus) fails, re-run Setup A’s install; Sophus is header-only so this is purely a cmake-config issue. (3) Set the initial guess to the truth and confirm LM converges in one or two iterations with tr_ratio ≈ 1 — a good habit for validating any residual you ever write.
1.10Reading List
A Micro Lie Theory for State Estimation in RoboticsSections 1.2–1.3 with the math filled in, in 17 pages instead of a differential-geometry course. The single most valuable read of this phase. manif: A Micro Lie Theory Library for State EstimationThe paper behind the library in 1.4 — same authors, same conventions, so paper and code annotate each other. State Estimation for RoboticsThe rigorous version of this entire chapter: matrix Lie groups, Gaussians on manifolds, GN and LM, every derivation shown. Modern Robotics: Mechanics, Planning, and ControlThe cleanest on-ramp to frames, SE(3), twists and screws (chapters 2–4). Read if section 1.1 went by too fast. Probabilistic RoboticsThe canon for section 1.5 and everything Phase 2 and Phase 4 build: Bayes filters, Gaussian filters, particle filters, MCL. Numerical OptimizationWhere GN, LM, trust regions, and line search are treated as first-class algorithms rather than folklore. Chapters 3, 4, 10. An Algorithm for Least-Squares Estimation of Nonlinear ParametersThe original LM paper: eleven pages, still readable, and the diag(H) scaling trick is already in it. Bundle Adjustment — A Modern SynthesisHow the loop of 1.8 scales to millions of residuals: sparsity, the Schur complement, gauge freedom. Dense but definitive. Ceres Solver DocumentationWhere Setup B leads: the modeling and solving chapters double as a well-written NLLS textbook with runnable examples.1.11Interview Questions
What does T_ab mean, and how do you keep frame conventions from biting you?
T_ab is the pose of frame b expressed in frame a — equivalently, the matrix mapping coordinates from b into a: p_a = T_ab · p_b. The payoff of the convention is mechanical checking: in any product, adjacent inner subscripts must match and cancel (T_ab · T_bc = T_ac), and the inverse swaps subscripts (T_ab⁻¹ = T_ba). In code, name every transform with both frames (T_base_lidar), never bare (lidar_tf), and test with asymmetric sensor mounts so ordering mistakes actually show up in the numbers.
Why can’t you average two rotations by averaging their matrices or Euler angles?
The mean of two rotation matrices is generally not a rotation matrix (orthogonality is not preserved by addition), and Euler angles wrap: +179° and −179° average arithmetically to 0° when the true mean heading is ±180°. Correct approaches stay on the manifold: for two rotations, walk halfway along the geodesic, R₁ · exp(½ log(R₁⁻¹R₂)); for many, iterate a tangent-space mean (average the log residuals about a guess, retract, repeat) or use the quaternion eigen-decomposition method — while handling the q ≡ −q double cover by sign-aligning quaternions first.
Explain the exponential and log maps like an engineer, not a mathematician.
exp wraps a small, flat, unconstrained vector (axis-times-angle for rotations; a 6-DoF twist for poses) onto the curved set of valid rotations/poses; log unwraps a rotation/pose displacement back into that flat vector. They are exact, closed-form (Rodrigues for SO(3)), and mutually inverse near the identity. Practically they let you do all arithmetic — corrections, averaging, covariances, Jacobians — on plain vectors, then convert the result back to a legal rotation, so no step of optimization ever produces a non-rotation.
Gauss-Newton versus Levenberg-Marquardt: what changes and when does it matter?
Both linearize residuals and solve normal equations; LM adds λ·diag(H) to the system before solving. That damping interpolates between GN (λ→0, full confident steps, near-quadratic convergence) and scaled gradient descent (λ large, short safe steps), with λ adapted by whether the last step reduced cost — a trust-region policy. It matters exactly when the initial guess is poor or the problem strongly nonlinear: GN’s full step overshoots and can diverge, LM shrinks the step until progress resumes. Near convergence they behave identically, so LM costs essentially nothing for the robustness it buys.
Why do these solvers use JᵀΣ⁻¹J instead of the true Hessian?
The true Hessian of a squared residual adds a term Σᵢ rᵢ·∇²rᵢ — second derivatives weighted by the residuals themselves. Near the solution residuals are small, so the term is negligible and JᵀΣ⁻¹J is an excellent approximation that needs only first derivatives, is positive semi-definite by construction (safe to solve), and inherits the problem’s sparsity. You get close-to-second-order convergence at first-order cost. The approximation degrades for large-residual, strongly curved problems — which is part of why outliers (large residuals) are so poisonous and get robust losses in practice.
What is gauge freedom, and what does it do to your optimization?
If all measurements are relative (odometry, loop closures, point correspondences), rigidly moving the entire solution changes no residual, so the cost function has a flat direction and H is singular — the solver either fails or silently returns one of infinitely many answers. The fix is to fix the gauge: add a prior factor pinning one pose (the standard), or hold a parameter block constant. In bundle adjustment the same disease also includes global scale for monocular cameras. Any time a solver reports rank deficiency on a relative-measurement problem, check the gauge before anything else.
How would you choose between Ceres, g2o, and GTSAM for a new project?
They solve the same damped sparse NLLS; choose by abstraction and workflow. Ceres: general cost functions with excellent autodiff — best for calibration, alignment, and "custom residual" problems, and it is what Cartographer and VINS-Fusion build on. g2o: vertices-and-edges pose graphs, analytic Jacobians, the classic choice and ORB-SLAM’s backend. GTSAM: factor graphs as the native language with iSAM2 incremental solving — best when measurements stream in and you need updates at sensor rate rather than batch re-solves, which is why modern LIO/VIO backends favor it. For a one-off calibration: Ceres. For a research pose-graph: g2o or GTSAM. For an online estimator: GTSAM.
Your optimizer converged. Give three reasons the answer can still be wrong.
First, local minimum: LM is a local method, and a bad initial guess converges confidently to the wrong basin — the cure is better initialization (odometry, RANSAC), not more iterations. Second, outliers under a quadratic cost: a single gross outlier’s influence grows with its residual, dragging the solution; robust kernels (Huber, Cauchy) cap that influence. Third, a modeling error: wrong noise covariances (one sensor trusted 100× too much), an unpinned gauge, or a residual whose Jacobian doesn’t match its cost. Convergence means the gradient is zero, not that the model was true — checking innovation statistics and per-residual costs is how you tell the difference.