Introduction
Apex Solver is a high-performance Rust library for nonlinear least squares optimization on Lie groups, aimed at SLAM, bundle adjustment and computer vision.
It bridges the gap between theoretical robotics and practical implementation:
- Manifold-aware optimization — full Lie group support (SO(2), SO(3), SE(2), SE(3), SE₂(3), SGal(3), Sim(3), ℝⁿ) with analytic Jacobians via the apex-manifolds crate.
- Three optimization algorithms — Levenberg–Marquardt (with Ceres-compatible
Nielsen and Marquardt damping policies), Gauss–Newton and Dog Leg behind a
unified
Optimizertrait. - Four sparse linear solvers — sparse Cholesky and QR, plus explicit and iterative (matrix-free PCG) Schur complement solvers for large-scale bundle adjustment.
- ~40 factors — pose, visual, IMU preintegration, LiDAR, GNSS, range and bearing, motion models and marginalization, each documented with its error and Jacobian in the Factor Reference.
- 15 robust loss functions — Huber, Cauchy, Tukey, Welsch, Barron and more, with correct Triggs correction of residuals and Jacobians.
- Zero-copy sparse linear algebra — parameters live in contiguous
nalgebrastorage viewed byfaerwithout copying;unsafeis forbidden crate-wide. - Ecosystem I/O — G2O, TORO and BAL file formats via the
apex-iocrate. - Live visualization — integrated Rerun support for watching poses, landmarks, cost curves and sparsity patterns during a solve.
Where to go next
- Installation & Quick Start — solve your first pose graph.
- Problem Construction — variables, factors, keys and gauge freedom.
- Factor Reference — every factor’s error and Jacobian.
- Optimizers — choosing and tuning LM, GN and Dog Leg.
- Linear Solvers — Cholesky, QR and the Schur family.
Installation & Quick Start
Installation
[dependencies]
apex-solver = "1.5.0"
Optional feature for live Rerun visualization:
[dependencies]
apex-solver = { version = "1.5.0", features = ["visualization"] }
Solving a pose graph from a G2O file
use apex_solver::core::problem::Problem;
use apex_solver::factors::pose::BetweenFactor;
use apex_solver::{G2oLoader, JacobianMode, ManifoldType};
use apex_solver::optimizer::levenberg_marquardt::{LevenbergMarquardt, LevenbergMarquardtConfig};
use nalgebra::dvector;
use std::collections::HashMap;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load pose graph from G2O file
let graph = G2oLoader::load("data/odometry/3d/sphere2500.g2o")?;
// Create optimization problem
let mut problem = Problem::new(JacobianMode::Sparse);
let mut var_keys = HashMap::new();
// Add SE3 poses as variables -- returns stable VarKey handles
for (&id, vertex) in &graph.vertices_se3 {
let quat = vertex.pose.rotation_quaternion();
let trans = vertex.pose.translation();
let se3_data = dvector![trans.x, trans.y, trans.z, quat.w, quat.i, quat.j, quat.k];
let key = problem.add_variable(ManifoldType::SE3, se3_data);
var_keys.insert(id, key);
}
// Add between factors (relative pose constraints) using VarKey handles
for edge in &graph.edges_se3 {
let k_from = var_keys[&edge.from];
let k_to = var_keys[&edge.to];
problem.add_residual_block(
&[k_from, k_to],
Box::new(BetweenFactor::new(edge.measurement.clone())),
None, // Optional: add HuberLoss for robustness
);
}
// Configure and run optimizer
let config = LevenbergMarquardtConfig::new()
.with_max_iterations(100)
.with_cost_tolerance(1e-6)
.with_compute_covariances(true); // Enable uncertainty estimation
let mut solver = LevenbergMarquardt::with_config(config);
let result = solver.optimize(&mut problem)?;
println!("Status: {:?}", result.status);
println!("Initial cost: {:.3e}", result.initial_cost);
println!("Final cost: {:.3e}", result.final_cost);
println!("Iterations: {}", result.iterations);
Ok(())
}
Result:
Status: CostToleranceReached
Initial cost: 1.280e+05
Final cost: 2.130e+01
Iterations: 5
Solving a BAL bundle adjustment problem
The bundle_adjustment binary solves BAL datasets with the iterative Schur
solver and self-calibration:
cargo run --release --bin bundle_adjustment -- \
data/bundle_adjustment/trafalgar/problem-257-65132-pre.txt
Programmatically the same shape is: SE(3) pose variable per camera, Rn(3)
variable per landmark, optional Rn(3) intrinsics per camera
(mark_for_elimination on the point keys), ProjectionFactor with a
HuberLoss. See Problem Construction next.
Problem Construction
Variables and keys
Variables are stored in a
slotmap-backed arena. add_variable returns a
stable, generational VarKey; keep the key and pass it wherever a factor needs
the variable. There are no string lookups on the hot path.
#![allow(unused)]
fn main() {
use apex_solver::{ManifoldType};
use nalgebra::dvector;
let pose = problem.add_variable(ManifoldType::SE3,
dvector![tx, ty, tz, qw, qx, qy, qz]);
let point = problem.add_variable(ManifoldType::RN, dvector![x, y, z]);
}
Supported manifold types: SO2, SO3, SE2, SE3, SE23, SGal3, Sim3,
RN. Parameters are stored in the manifold’s representation size (e.g. 7
doubles for SE(3)); optimization happens in the tangent space (6 DOF for
SE(3)) via a right-perturbation retraction.
That distinction runs through everything downstream: Jacobians have tangent width, the step is applied with rather than addition, and the linear system is built in tangent coordinates. See From Factor Graph to Linear System.
Residual blocks
A residual block binds one factor to the variables it reads:
#![allow(unused)]
fn main() {
problem.add_residual_block(&[k_from, k_to], Box::new(between_factor), loss);
}
- The factor implements
Factor::linearize(params, residual, jacobian)— see Factors. lossis an optionalBox<dyn LossFunction>; see Robust Loss Functions.- To weight the block by its measurement uncertainty, use
add_residual_block_with_noiseand pass aNoiseModel. Without one the block is unweighted, which is only correct when every residual in the graph is already in comparable units. - Use
try_add_residual_blockfor aResult-returning registration that runs the factor’svalidate_variableshook — shape mismatches are caught at registration time instead of during parallel evaluation.
Gauge freedom
A pose graph is unconstrained up to a global rigid transform. Anchor it by fixing DOFs of one pose:
#![allow(unused)]
fn main() {
for dof in 0..6 {
problem.fix_variable(pose_keys[0], dof);
}
}
Alternatively, register a PriorFactor on the first pose.
Schur landmarks
Schur-complement solvers eliminate landmark blocks. Mark the variables to eliminate:
#![allow(unused)]
fn main() {
problem.mark_for_elimination(point_key);
}
Every Schur solver (ExplicitSparseSchur, ExplicitDenseSchur,
ImplicitSparseSchur) can also classify automatically from manifold type and
size when the ordering is explicitly opted in:
#![allow(unused)]
fn main() {
use apex_solver::linalg::SchurOrdering;
let ordering = SchurOrdering::default().with_auto_detect(true);
}
Auto-detection is off by default: Rn(3) is ambiguous between 3-D
landmarks and self-calibration intrinsics ([focal, k1, k2]), and eliminating
intrinsics as landmarks silently corrupts the Schur complement.
What elimination buys, and which Schur solver to pick, is in Linear Solvers & the Schur Complement.
Factor Reference
A factor is one measurement’s contribution to the objective: given the variables it connects, it produces a residual vector and its Jacobian. Apex Solver ships about forty of them, grouped by the sensor they model.
The contract
#![allow(unused)]
fn main() {
pub trait Factor: Send + Sync {
fn linearize(
&self,
params: &[&[f64]],
residual: &mut [f64],
jacobian: Option<faer::mat::MatMut<'_, f64>>,
);
fn residual_dim(&self) -> usize;
fn jacobian_shape(&self) -> (usize, usize);
fn validate_variables(&self, variables: &[&dyn ManifoldVariable]) -> Result<(), String> { Ok(()) }
fn whitens_internally(&self) -> bool { false }
}
}
linearize writes into caller-owned buffers and allocates nothing — it runs
once per factor per iteration, in parallel across disjoint buffers. params
arrives as one zero-copy &[f64] slice per connected variable, in the order
the block was registered. jacobian is column-major with shape
jacobian_shape(), whose columns are the minimal (tangent) dimensions, not
the parameter dimensions: an SE3 block is 7 parameters but 6 columns.
Four conventions worth knowing
Poses are body-in-world. T_wb places the body in the world, so
pose.translation() is the body’s world position and a body-frame direction
is . Some camera factors take a
world-to-camera pose instead; each says so.
Jacobians are with respect to the right perturbation. For a Lie group
variable the solver updates , so every Jacobian column is
at . Take these derivatives
from apex-manifolds — every group reports the Jacobians of its own
operations (act, compose, log, right_plus, right_minus) through
optional output arguments. Re-deriving a block such as
by hand creates a second copy of a
convention that can silently drift from the group’s.
A factor either whitens internally or takes a NoiseModel — never both.
Most return the raw residual and let the noise model supplied at registration
whiten it. A few cannot, because their weighting is bound up with the
measurement itself; they report whitens_internally() == true and must be
registered with NoiseModel::null(). Registering one with a noise model is
rejected rather than silently double-weighted.
validate_variables is the real shape check. [profile.test] inherits
release, so the debug_assert!s factors also carry are compiled out of
cargo test. Every factor implements this hook, so a mismatched registration
is a typed error instead of a panic inside the parallel assembly.
Writing your own
Implement the trait, then test it in this order — the order matters:
- A hand-computed residual. Pick a configuration whose answer you can work out on paper and assert it. This is the only test that pins your convention; everything below is self-referential without it.
- Finite differences on the Jacobian, away from the solution. Perturb
through the manifold’s
right_plus, not the raw parameters. Evaluate at a point where the residual is not near zero: residual comparisons pass through , which tends to the identity as the residual tends to zero, so a Jacobian checked only at the truth can be badly wrong and still pass. - A solved graph.
tests/factor_coverage.rshas one scenario per factor, and a guard test that reads these modules’pub uselines and fails if an exported factor has no scenario.
Step 1 is not optional. A finite-difference test compares a Jacobian against
its own residual, so a residual that is self-consistently wrong passes it —
which is exactly how a BearingRangeFactor that rotated the wrong way, and a
GpsAsyncFactor missing gravity, both survived in this repo.
Index
| Group | Module | Covers |
|---|---|---|
| Pose & priors | factors::pose | relative constraints, anchors |
| Visual | factors::visual | reprojection, stereo, epipolar, calibration |
| IMU | factors::imu | preintegration on SE₂(3) and SGal(3) |
| LiDAR | factors::lidar | ICP, LOAM edge/plane, GICP |
| GNSS & navigation | factors::navigation | GNSS, barometer, attitude |
| Range & bearing | factors::ranging | UWB, radar, bearing-only |
| Motion models | factors::motion | ZUPT, ZARU, nonholonomic, planar |
| Marginalization | factors::marginal | sliding-window priors |
Factors are addressed by their module path — factors::visual::StereoFactor,
factors::imu::se23::ImuFactor — so an import says which sensor a measurement
came from. apex_solver::prelude re-exports only the handful nearly every
program needs.
Noise Models
A factor produces a raw residual in measurement units — metres, pixels, radians. A noise model converts it into a statistically comparable quantity so that a 2 cm range error and a 0.5 px reprojection error can be summed in one objective. This is the piece that makes the least-squares problem a maximum likelihood estimate rather than an arbitrary weighted sum.
What it stores
NoiseModel (core::noise) holds the square-root information, not the
covariance. Writing for the information matrix and
for its factor, whitening is
so that
which is the Mahalanobis distance. Storing rather than means whitening is a multiply, with no factorization on the hot path.
Three representations, chosen by what the measurement actually needs:
| Variant | Stores | Cost |
|---|---|---|
Null | nothing | Zero — no allocation, no arithmetic |
Diagonal(DVector) | one entry per residual row | One multiply per row |
Dense(DMatrix) | full dim × dim factor | Triangular multiply |
Diagonal covers the block-diagonal that most SLAM graphs use.
Reach for Dense only when residual components are genuinely correlated.
Constructing one
#![allow(unused)]
fn main() {
use apex_solver::core::noise::NoiseModel;
// From standard deviations — the common case. sqrt_info_i = 1/σ_i.
let noise = NoiseModel::from_sigmas(&[0.05, 0.05, 0.02])?;
// From square-root information directly (1/σ per row).
let noise = NoiseModel::from_diagonal_sqrt_info(&[20.0, 20.0, 50.0])?;
// From a full information matrix Ω (symmetric PSD).
let noise = NoiseModel::from_information(omega)?;
// From an already-factored S.
let noise = NoiseModel::from_sqrt_info(s)?;
// Unweighted.
let noise = NoiseModel::null();
}
Register it with the block:
#![allow(unused)]
fn main() {
problem.add_residual_block_with_noise(&[key_a, key_b], factor, loss, noise);
}
Rank-deficient and indefinite information
Real datasets carry information matrices that are not positive definite. A g2o file may encode zero roll/pitch information for a planar robot, giving a rank-deficient ; floating-point round-trips can leave slightly negative eigenvalues.
from_information handles this by clamping negative eigenvalues to zero — the
nearest-PSD projection — and taking the symmetric square root
, which satisfies
. Clamped directions whiten to zero, i.e. the
measurement stops constraining them.
That clamp is silent, and on a genuinely ill-formed it deletes real constraints rather than empty ones. When the difference matters, use the reporting form:
#![allow(unused)]
fn main() {
let (noise, repair) = NoiseModel::from_information_reporting(omega)?;
if repair.is_material() {
// `repair.clamped_directions`, `repair.min_eigenvalue` and the scale it is
// judged against tell you whether this was floating-point noise or a
// measurement whose Ω is wrong.
}
}
InformationRepair reports how many directions were clamped, the smallest
eigenvalue before clamping, and the largest eigenvalue it is judged against —
relative_indefiniteness is the ratio that distinguishes round-off from a
broken measurement.
RepairStrategy then decides what to do with a materially-repaired edge:
| Strategy | Effect |
|---|---|
Clamp (default) | Keep the PSD projection. The objective stays the Ω-weighted χ², minus the clamped directions |
UnitWeight | Substitute an identity weight for that edge. Trades χ² for unweighted cost — the reported objective must be labelled accordingly |
This is not hypothetical: the cubicle pose-graph dataset ships indefinite
on some edges, and switching those to unit weight moves the
unweighted cost by a factor of ~7. Whichever you choose, say which objective
you are reporting.
Whiten internally, or take a noise model — never both
Most factors return a raw residual and let the registered NoiseModel whiten
it. Some cannot: their weighting is inseparable from the measurement — GICP’s
combined point covariance, a smart factor’s internal elimination, an IMU
preintegration’s propagated information matrix. Those whiten as they go and
report Factor::whitens_internally() == true.
Pairing an internally-whitened factor with a non-null noise model whitens twice and silently over-weights the block. The two conventions are indistinguishable by inspecting a residual, so registration rejects the combination outright rather than letting it through:
#![allow(unused)]
fn main() {
// Rejected at registration — the factor already applied its information.
problem.try_add_residual_block_with_noise(&keys, gicp_factor, None, dense_noise)?;
// Correct.
problem.try_add_residual_block_with_noise(&keys, gicp_factor, None, NoiseModel::null())?;
}
Where whitening happens in the pipeline
Whitening runs after the factor linearizes and before the robust loss — see From Factor Graph to Linear System. The ordering is not incidental: a robust loss decides whether a residual is an outlier, and that is a question about standard deviations, not raw units. A 3 px reprojection error is an outlier only relative to the measurement’s σ.
Choosing σ
The noise model is a modelling decision, not a tuning knob, and getting it wrong biases the estimate rather than just slowing convergence:
- Too small σ (over-confident) — the factor dominates and pulls the solution toward that measurement, over-riding better-informed ones.
- Too large σ (under-confident) — the factor is effectively ignored.
- Relative scale is what matters. Multiplying every σ in the graph by a constant leaves the optimum unchanged; it only rescales the cost. What changes the answer is the ratio between different sensors’ models.
For a robust loss to work as intended, σ should reflect the inlier distribution — the loss then handles the tail. Inflating σ to “cover” outliers and also applying a robust loss down-weights inliers twice.
Robust Loss Functions
Outliers dominate the ordinary least-squares objective. Apex Solver ships 15
robust kernels — Huber, Cauchy, Tukey, Welsch, Fair, GemanMcClure,
Andrews, Trimmed, L1, Lp, Barron variants and more — all in
apex_solver::core::loss_functions.
Usage
#![allow(unused)]
fn main() {
use apex_solver::core::loss_functions::HuberLoss;
problem.add_residual_block(
&[k_pose, k_landmark],
Box::new(projection_factor),
Some(Box::new(HuberLoss::new(1.345)?)),
);
}
What the solver actually computes
For a squared residual norm , the robust objective is
Minimizing is equivalent to an iteratively reweighted least-squares problem whose residual and Jacobian are
with from the Triggs correction. Apex Solver applies exactly this correction in-place during block evaluation, and reports the cost as — not as the norm of the corrected residual. The two are different functions; conflating them silently corrupts every reported cost and trust-region ratio on robust problems.
From Factor Graph to Linear System
This chapter follows one optimizer iteration end to end: how the factor graph is
turned into J and r, how each factor’s contribution is whitened, robustified
and scattered into the global system, and how the solved step gets back onto the
manifold. It describes what the code in core/ and linearizer/ actually does.
The graph
A Problem holds two arenas (core::problem):
- Variables — the unknowns, each a manifold element (
SE3,SO3,Rn, …), stored in aSlotMapand addressed by a stableVarKey. - Residual blocks — the factors, each a
ResidualBlockholding one boxedFactor, theVarKeys it connects, an optionalNoiseModel, and an optional robustLossFunction.
This is the factor graph: variables are nodes, residual blocks are the (hyper-)edges connecting them. There is no separate graph structure.
#![allow(unused)]
fn main() {
let pose_i = problem.add_variable(ManifoldType::SE3, pose_i_params);
let pose_j = problem.add_variable(ManifoldType::SE3, pose_j_params);
problem.add_residual_block(
&[pose_i, pose_j], // the edge's endpoints
Box::new(BetweenFactor::new(measurement)),
Some(Box::new(HuberLoss::new(1.0)?)),
);
}
Each block is assigned a residual_row_start_idx — its offset in the stacked
residual vector — and each variable a column offset in the tangent space. Those
two offsets are what turn a graph into a matrix.
What the objective actually is
Stacking every block’s residual gives , and the problem is
where is the whitened residual of block and its robust loss (identity when none is set). The three sections below are exactly the three transformations between “what the factor computes” and “what the solver sees”.
Step 1 — the factor linearizes itself
Every factor implements one method (factors::Factor):
#![allow(unused)]
fn main() {
fn linearize(
&self,
params: &[&[f64]], // one slice per connected variable
residual: &mut [f64], // output, length residual_dim()
jacobian: Option<faer::mat::MatMut<'_, f64>>, // output, jacobian_shape()
);
}
Given the current parameter values, it writes
where is the predicted measurement, the observation, and
whatever difference the measurement space requires — subtraction
for a Euclidean measurement, between + log for a pose measurement.
The Jacobian is with respect to the tangent space at the current estimate,
not the parameter vector. For an SE3 variable stored as 7 parameters the
block has 6 columns, and it is the derivative of the residual with respect to a
local perturbation applied as . This is what
makes the linear system well-posed on a manifold: the 7-parameter quaternion
representation is over-parameterised and would give a singular
.
Factors must obtain these derivatives from the manifold, not re-derive them. Every group in
apex-manifoldsreports the Jacobians of its own operations (act,compose,log,right_plus,between) through optional output arguments. A hand-written[R | −R[p]ₓ]is a second copy of a convention that can silently drift from the group’s —factors::commondeliberately contains no manifold derivatives for this reason.
The buffers are pre-allocated and reused across iterations; linearize never
allocates.
Step 2 — whitening by the noise model
A measurement with covariance must be weighted by its information.
The NoiseModel stores the square-root information
and applies it (core::noise):
so that —
the Mahalanobis distance. A Diagonal model is a per-row scale; a Dense model
is a triangular multiply.
Whitening happens before the robust loss, in both the block-evaluation path and the assembly path, and the two are documented to keep the same ordering. The loss must see the whitened norm, because “is this residual an outlier?” is a question about standard deviations, not raw units.
A factor either whitens internally or takes a
NoiseModel— never both. Some factors’ weighting is inseparable from the measurement (GICP’s combined point covariance, a smart factor’s internal elimination, an IMU preintegration’s information matrix), so they whiten as they go and reportwhitens_internally() == true. Registering one of those with a non-null noise model would whiten twice and silently over-weight the block; the two conventions are indistinguishable by inspecting a residual, so registration rejects the combination outright.
Step 3 — the robust loss becomes a reweighting
A robust loss turns the problem into a different least-squares
problem rather than changing the solver. core::corrector implements Ceres’s
corrector: given and the loss derivatives
,
so that and the gradient is correct. The rank-one term is what makes this more than naive iteratively-reweighted least squares: it carries the curvature of , so the Gauss-Newton model of the robustified problem stays accurate.
Ordering matters and is load-bearing: the Jacobian correction reads the
un-corrected residual, so correct_jacobian_in_place runs before
correct_residual_in_place. Reversing them silently changes the rank-one term.
After this step the solver sees an ordinary least-squares problem in — every optimizer and linear solver downstream is unaware that a robust loss was involved at all.
Step 4 — scattering into the global system
Each block has produced a small dense
Jacobian, where cols is the sum of its variables’ DOFs. Assembly places those
numbers into the global (linearizer::cpu):
- block ’s rows go at its
residual_row_start_idx, - the sub-block for variable goes at ’s column offset.
The result is block-sparse: a row touches only the variables its factor connects, which is precisely the graph’s adjacency.
The sparsity pattern is symbolic and built once. SymbolicStructure
computes the pattern and a value permutation before the iteration loop, and
every subsequent iteration only refills values in that fixed layout — the
pattern is a property of the graph, not of the estimate. Recomputing it per
iteration is the single most common way to make a solver slow.
Assembly is parallelised over blocks with rayon into disjoint row ranges, so no two threads write the same row. This is why the visiting order is load-bearing: values are pushed positionally against the symbolic pattern’s pairs, so any disagreement between the two orders would permute ’s rows against — a wrong system that still factorizes cleanly.
Fixed variables (gauge freedom)
A pose-graph or BA problem is invariant under a global transformation, so
is singular unless something is pinned. fix_variable
holds individual tangent components fixed:
#![allow(unused)]
fn main() {
for dof in 0..6 {
problem.fix_variable(first_pose, dof); // anchor the gauge
}
}
Fixed components are excluded from the column space, so they never enter the linear system and cannot move.
Step 5 — solve, and return to the manifold
The optimizer now has and , and forms
Which linear system gets solved is the optimizer’s business (Optimizers) and how it is solved is the backend’s (Linear Solvers). Both produce a tangent-space step .
The step is not added to the parameters. It is applied through the manifold’s right-plus:
for each variable’s own group, using the same right convention the Jacobians were taken with. Mixing conventions here — taking a left-Jacobian and applying a right update — produces a step that is wrong by a factor of the adjoint, and it still converges, just slowly and to a slightly different place.
The loop
build symbolic structure once, from the graph's adjacency
─── per iteration ────────────────────────────────────────────────
for each residual block (parallel):
factor.linearize(params) ──────────► r_i, J_i tangent-space
noise.whiten(...) ──────────► r̃_i, J̃_i Mahalanobis
corrector.correct(...) ──────────► r̂_i, Ĵ_i robustified
scatter into J, r at (row_start, col_offsets)
─────────────────────────────────────────────────────────────────
solve for h (Gauss-Newton / LM / Dog Leg × linear backend)
x ← x ⊞ h per-variable right-plus
check convergence
Every iteration repeats steps 1–5 at the new estimate: the residuals, the Jacobians and the robust weights are all recomputed, while the sparsity pattern and every cached symbolic structure are reused.
Why this is efficient
- Symbolic once, numeric many. The pattern of , of , and the permutation linking them are computed once and reused, so an iteration is a parallel value gather plus a parallel product.
- Blocks are independent. Linearization is embarrassingly parallel; only the scatter needs disjoint ranges, which the row layout guarantees.
- No allocation in the hot path. Factor buffers, the assembly arena and the solver workspaces are allocated once and reused.
- Manifold parameters stay contiguous. They live in
nalgebrastorage thatfaerviews without copying, so no conversion happens between assembly and the linear solve. - Structure is exploited, not discovered. The Schur partition, the elimination groups and the chunk layout all come from the graph’s adjacency, which is known before the first iteration.
Optimizers
This chapter documents what the three optimizers in src/optimizer/ actually
do — the equations as implemented, the defaults as coded, and the deviations
from textbook forms with the reason the code gives for each. Every formula names the module
it comes from.
The problem all three solve
Given residual blocks over manifold variables, Problem defines
Variables live on manifolds, so the step is applied with rather
than addition: , with the per-group right-plus
from apex-manifolds. The Jacobian is therefore taken with respect to
the tangent space, and every matrix below has tangent dimension, not
parameter dimension. An SE3 variable contributes 6 columns, not 7.
Two quantities recur:
H drops the term of the true Hessian, which is
the Gauss-Newton approximation and is accurate when residuals are small or
nearly linear (gauss_newton.rs).
Sign convention.
LinearSolver::get_gradientreturns , not the right-hand side (linalg/mod.rs). Each optimizer negates it before forming the linear system.tests/linear_solver_contract.rspins this for every backend, because getting it backwards produces a step that ascends.
Gauss-Newton
optimizer/gauss_newton.rs. Solves the normal equations exactly and takes the
full step, with no step-size control at all (gauss_newton.rs):
There is no damping, no trust region, and no line search. Convergence is quadratic near a solution where the Gauss-Newton approximation holds, and the method diverges when started far away or when is ill-conditioned. Use it when you already know the problem is well-conditioned and the initial guess is close; otherwise use LM.
Options
| Field | Default | Meaning |
|---|---|---|
linear_solver_type | SparseCholesky | Which backend solves the normal equations |
min_diagonal | 1e-10 | Conditioning floor |
max_iterations | 50 | Iteration cap (Ceres’s default) |
cost_tolerance | 1e-6 | Relative cost-change stop |
parameter_tolerance | 1e-8 | Relative step-size stop |
gradient_tolerance | 1e-10 | First-order optimality stop |
use_jacobi_scaling | false | Column equilibration |
max_condition_number | None | Optional conditioning guard |
min_cost_threshold | None | Early exit once |
compute_covariances | false | Populate SolverResult::covariances |
timeout | None | Wall-clock cap |
The schur_* fields (schur_variant, schur_preconditioner,
schur_cg_max_iterations, schur_cg_tolerance, schur_cg_q_tolerance) exist
on all three configs and are forwarded to the linear solver — see
Linear Solvers.
Levenberg–Marquardt
optimizer/levenberg_marquardt.rs. The default optimizer, and the one
for_bundle_adjustment() presets.
The damped system
D is not the identity. It is the clamped diagonal of the Hessian
(linalg/mod.rs):
This is Ceres’s LevenbergMarquardtStrategy: damping each column in proportion
to its own curvature makes the step invariant to a rescaling of the parameters,
which uniform is not. Damping::identity sets
and recovers plain exactly.
The lower clamp matters: without it a column with no curvature would receive no damping at all, leaving that direction unconstrained.
Step quality
After solving, the step is scored by the gain ratio. Predicted reduction comes
from the quadratic model (optimizer/mod.rs):
evaluated with the un-damped — hessian_vec_product is documented
to never carry , because damping in the model would corrupt
(linalg/mod.rs). Then (optimizer/mod.rs):
with two guards the textbook ratio does not have:
- → . A negative predicted reduction means the model says the step increases cost, so it is rejected outright rather than producing a misleading positive ratio.
- → if the actual reduction was positive, else .
The step is accepted when min_relative_decrease, default
1e-3 (levenberg_marquardt.rs), matching Ceres. This is not the
textbook .
Damping update — two rules
Selected by damping_update (levenberg_marquardt.rs).
Nielsen (default, what Ceres uses). On an accepted step:
On a rejected step and ,
so consecutive failures escalate geometrically. Reads damping_nu; ignores
damping_increase_factor, damping_decrease_factor, min_step_quality,
good_step_quality.
Marquardt. Three bands:
A rejected step always increases whatever was — the
code notes that leaving it unchanged would recompute the identical step next
iteration and stall (levenberg_marquardt.rs). Afterwards is
clamped to [damping_min, damping_max].
Options
| Field | Default | Notes |
|---|---|---|
damping (λ₀) | 1e-4 | Ceres’s initial_trust_region_radius = 1e4 corresponds to λ = 1/radius |
damping_min / damping_max | 1e-12 / 1e12 | Clamp after every update |
damping_increase_factor | 10.0 | Marquardt only |
damping_decrease_factor | 0.3 | Marquardt only |
damping_nu (ν₀) | 2.0 | Nielsen only |
min_diagonal / max_diagonal | 1e-6 / 1e32 | The clamp on D |
min_relative_decrease | 1e-3 | Acceptance threshold on ρ |
good_step_quality | 0.75 | Marquardt only |
min_step_quality | 0.0 | Marquardt only |
max_consecutive_rejected_steps | 5 | Then StalledNoProgress |
max_iterations | 50 | Ceres’s default |
cost_tolerance | 1e-6 | |
parameter_tolerance | 1e-8 | |
gradient_tolerance | 1e-10 | |
use_jacobi_scaling | false | Off because it is incompatible with Schur block structure (levenberg_marquardt.rs) |
Note the scaling default differs between optimizers: LM defaults to false,
Dog Leg to true. Enable it for LM on mixed-scale problems solved with
Cholesky or QR.
for_bundle_adjustment() preset
levenberg_marquardt.rs overrides: ImplicitSparseSchur linear solver,
SchurJacobi preconditioner, λ₀ = 1e-3 (the plain default is 1e-4),
max_iterations = 20,
cost_tolerance = 1e-6, parameter_tolerance = 1e-8,
gradient_tolerance = 1e-10. The solver choice is justified by the measured
table in that comment; see Benchmarks.
Dog Leg
optimizer/dog_leg.rs. Powell’s dog leg over a spherical trust region of
radius , interpolating between the steepest-descent and
Gauss-Newton directions.
Cauchy point
The unconstrained minimiser along (dog_leg.rs):
with a guard: if then .
Step selection — three cases
From dog_leg.rs, given :
Case 1 — GN step fits. If , take
(StepType::GaussNewton).
Case 2 — Cauchy point already outside. If ,
take the steepest-descent direction scaled to the boundary
(StepType::SteepestDescent):
Case 3 — interpolate. Otherwise walk from toward to the boundary. With , solve , i.e. with
The implementation does not use the textbook quadratic root. It uses
Ceres’s cancellation-avoiding branch (dog_leg.rs), with
:
Choosing by the sign of avoids catastrophic cancellation in the
subtraction. Fallbacks: when (geometrically
impossible, handled anyway) or (degenerate
). Finally is clamped to and
(StepType::DogLeg).
Trust-region update
dog_leg.rs. Growth is Ceres’s rule, not a fixed doubling:
and is left unchanged in between.
is measured in the scaled space when Jacobi scaling is on — which is Dog Leg’s default. The code documents why: the radius bounds the scaled step, so feeding the un-scaled norm would compare two different units and grow by an arbitrary factor (
dog_leg.rs).
Step reuse
On a rejected step Dog Leg caches the GN step, Cauchy point, gradient and
, and reuses them next iteration (dog_leg.rs). Only
changed, so the expensive linear solve does not have to be redone.
The cache is invalidated on any accepted step, because the parameters moved.
Controlled by enable_step_reuse (default true).
Options
| Field | Default | Notes |
|---|---|---|
trust_region_radius (Δ₀) | 1e4 | Ceres-style large initial radius |
trust_region_min / max | 1e-12 / 1e12 | Δ floor triggers TrustRegionRadiusTooSmall |
trust_region_increase_factor | 3.0 | The 3 in max(Δ, 3‖h‖) |
trust_region_decrease_factor | 0.5 | |
good_step_quality | 0.75 | Grow above this |
poor_step_quality | 0.25 | Shrink below this |
min_step_quality | 0.0 | Acceptance threshold |
use_jacobi_scaling | true | Elliptical trust region |
initial_mu / min_mu / max_mu | 1e-4 / 1e-8 / 1.0 | Adaptive regularisation for a singular |
mu_increase_factor | 10.0 | μ shrinks as μ / (0.5·factor) on success |
enable_step_reuse | true | See above |
Jacobi column scaling
When use_jacobi_scaling is on, columns are equilibrated before the solve
(optimizer/mod.rs):
The solve happens in the scaled space and the step is mapped back with . The in the denominator keeps the scaling finite for a structurally empty column.
Ordering matters and the code is explicit about it: the predicted reduction
is computed before un-scaling (levenberg_marquardt.rs), because the
solver’s cached gradient and Hessian are the scaled ones and all three vectors
must live in the same space. The predicted reduction is a value of the
quadratic model and is invariant under the change of variables, so it is
equally valid for the un-scaled step.
Convergence criteria
All three optimizers share check_convergence (optimizer/mod.rs),
evaluated in this order.
Always checked, before anything else:
InvalidNumericalValues— any of new cost, step norm or gradient norm is NaN or infinite.Timeout— elapsed ≥timeout.MaxIterationsReached— iteration ≥max_iterations.
Only after an accepted step (a rejected step cannot terminate the solve):
GradientToleranceReached— .- From iteration 1 onward,
ParameterToleranceReached: — a relative test, so it scales with the parameter magnitude. - From iteration 1 onward,
CostToleranceReached: . MinCostThresholdReached— optional cutoff.TrustRegionRadiusTooSmall— LM and Dog Leg only.
LM additionally reports StalledNoProgress after
max_consecutive_rejected_steps consecutive rejections
(levenberg_marquardt.rs): damping has grown until every trial step is
negligible, so the remaining budget cannot change the cost.
Output
All three return SolverResult<T> (optimizer/mod.rs):
| Field | Meaning |
|---|---|
parameters | Final values |
status | The OptimizationStatus above |
initial_cost / final_cost | at start and end |
iterations | Outer iterations performed |
elapsed_time | Wall clock for the solve |
convergence_info | Per-iteration history, when recorded |
covariances | Per-variable tangent-space covariance; None unless compute_covariances |
Note that several statuses are successful terminations, not failures:
Converged, CostToleranceReached, GradientToleranceReached,
ParameterToleranceReached and StalledNoProgress are all treated as
converged by the benchmark harness. MaxIterationsReached means the budget ran
out with the solve still improving.
Linear Solvers
Every optimizer iteration reduces to one linear solve. This chapter documents
the backends in src/linalg/: what each one computes, the equations as
implemented, and how to configure them. Formulas name the module they come from.
Two traits define the contract (linalg/mod.rs):
| Method | Returns |
|---|---|
solve_normal_equation(r, J) | solving |
solve_augmented_equation(r, J, damping) | solving |
hessian_vec_product(v) | for the un-damped |
get_gradient() | — note the sign |
get_hessian() | when the backend happens to hold one; None is valid |
get_hessian() returning None is not a failure. A backend that eliminates
straight from never materialises , and callers must
degrade to hessian_vec_product rather than error — that is what makes the
matrix-free paths expressible.
JacobianMode picks the assembly: Sparse (default) or Dense (below ~500
DOF). LinearSolverType then picks the algorithm within that mode, and the
optimizer rejects mismatched combinations (levenberg_marquardt.rs).
Normal equations
For the non-Schur backends, and
are formed once per iteration (sparse/normal_eq.rs). The sparsity of
, of , and the value permutation linking them are
cached across iterations, so each evaluation is a parallel value gather plus
faer’s parallel sparse product. The cache is keyed on ’s sparsity, which
must not change between iterations.
Sparse Cholesky
SparseCholeskySolver — factorization of (or
). Symbolic factorization cached, numeric pass repeated.
The default for pose graphs.
Sparse QR
SparseQRSolver — factorizes directly rather than forming ,
which squares the condition number. More robust on ill-conditioned problems, at
a higher per-iteration cost.
Dense Cholesky / QR
DenseCholeskySolver, DenseQRSolver — for JacobianMode::Dense. Both route
their SPD solves through one shared solve_spd helper (dense/cholesky.rs)
so the factorization and error mapping exist in exactly one place.
The Schur complement
Why
In bundle adjustment most variables are landmarks. Ladybug has 1,723 cameras and 156,502 landmarks: 15,507 retained DOF against 469,506 eliminated. Landmarks are mutually unconnected — no factor touches two of them — so the landmark block of the Hessian is block-diagonal and can be eliminated cheaply, leaving a system in the cameras alone.
The algebra
Partition the variables into an eliminated set (e, Ceres’s “group 0”) and a
retained set (k, “group 1”). The system becomes
(linalg/schur/partition.rs):
Eliminating gives the reduced system:
and back-substitution recovers the eliminated half:
Nothing here mentions cameras or points — the partition is agnostic to what the variables mean, which is why the same code covers classic BA, inverse-depth parameterisations, LiDAR features and marginalization.
The precondition, and how it is enforced
The elimination is exact only when is block-diagonal, i.e. the eliminated variables are mutually unconnected. Violating it yields a wrong step with no other symptom, so it is checked rather than assumed:
- Solvers that form call
SchurPartition::verify_block_diagonal(partition.rs) — one pass over the eliminated columns’ nonzeros. - The matrix-free path never forms , so it checks against ’s rows
instead: a residual row touching two eliminated variables is the same
violation (
sparse/schur/implicit.rs,ensure_structure).
Both report a typed error naming the two offending variables.
Damping under Schur
is applied to both blocks before elimination — damping the
reduced system instead would not be the same problem
(levenberg_marquardt.rs, and explicit.rs):
Landmark block inversion
is formed blockwise. Every Schur solver shares one
regularized-retry policy, EliminatedBlocks::invert_in_place
(partition.rs), so a landmark is regularized identically regardless of
which solver eliminated it.
Choosing what to eliminate
SchurOrdering (linalg/schur/ordering.rs) classifies variables. Manual
marks always apply:
#![allow(unused)]
fn main() {
problem.mark_for_elimination(landmark_key);
}
Auto-classification by manifold type and DOF is available but off by default:
#![allow(unused)]
fn main() {
let ordering = SchurOrdering::new().with_auto_detect(true);
}
The default is off for a specific reason (
ordering.rs):Rn(3)is also how self-calibration represents intrinsics[focal, k1, k2]. Auto-detecting “anyRn(3)is a landmark” would silently eliminate intrinsic blocks and corrupt . Turn it on only when you know everyRn(3)in the problem is a landmark.
The four Schur solvers
LinearSolverType | Sub-config | Mode | Ceres equivalent |
|---|---|---|---|
ExplicitSparseSchur | ExplicitSchurVariant::Sparse | Sparse | SPARSE_SCHUR |
ExplicitSparseSchur | ExplicitSchurVariant::Chunked | Sparse | SchurEliminator |
ExplicitSparseSchur | ExplicitSchurVariant::Iterative | Sparse | ITERATIVE_SCHUR + use_explicit_schur_complement |
ImplicitSparseSchur | preconditioner | Sparse | ITERATIVE_SCHUR |
ExplicitDenseSchur | — | Dense | DENSE_SCHUR |
ExplicitSchurVariant::Sparse
Forms , builds explicitly, factorizes with sparse Cholesky. The most accurate: it solves the reduced system exactly, and is the reference the other paths are measured against.
is accumulated into a dense buffer and filtered
back to sparse (explicit.rs). That buffer is the current scaling limit —
1.9 GB at Ladybug’s 15,507 retained DOF.
ExplicitSchurVariant::Chunked
Algebraically identical to Sparse — the benchmark confirms bit-identical
RMSE on all four datasets — but builds chunk by chunk directly from
, never forming , , or the value
permutation. On the largest BAL problem those account for ~24 GB of the ~32 GB
needed before elimination can start.
Per chunk (the rows sharing one eliminated variable), with and :
This trades time for memory and is slower in wall clock — 1.2×–2.1× the
Sparsevariant. The chunk sweep is inherently serial: reading rows out of a column-major without building a CSR copy relies on one forward-only cursor per retained column, which forces chunks to be visited in increasing row order. The path it replaces is fully parallel, so on a many-core machine the lost parallelism outweighs the saved work. Requires each eliminated variable’s rows to be contiguous, whichProblem::group_rows_for_eliminationarranges.
ExplicitSchurVariant::Iterative
Forms explicitly like Sparse, then solves it with PCG instead of
Cholesky. Carries the same memory cost while solving
less exactly, so it buys nothing on memory — but it measures 0.5×–1.3× Sparse
on time.
ImplicitSparseSchur — the matrix-free path
Forms neither nor . Writing
, PCG only ever needs ’s action on a vector, and
expanding that action so it reads alone gives the whole solver
(implicit.rs):
Four passes over ’s nonzeros, no intermediate matrix. This is why it
exists: for BA carries a dense block for every pair of
cameras sharing a landmark — fill-in does not have — so cost scales
with rather than . Ceres
states the same cost model for ITERATIVE_SCHUR.
Everything else it needs also comes from (linalg/schur/jacobian_ops.rs):
the gradient , the damping diagonal
, and the
blocks as column dot products.
get_hessian() returns None here; the quadratic model is served exactly as
.
This is what for_bundle_adjustment() selects.
ExplicitDenseSchur
The same explicit construction over a dense Hessian, for JacobianMode::Dense.
Targets a few thousand DOF — a dense for the smallest BAL
dataset would be ~485k × 485k.
Preconditioners
SchurPreconditioner applies to both PCG paths. The three match Ceres’s
options for ITERATIVE_SCHUR:
| This crate | Ceres | Built from |
|---|---|---|
None | IDENTITY | — (unpreconditioned) |
BlockDiagonal | JACOBI | diagonal blocks of (i.e. ) |
SchurJacobi (default) | SCHUR_JACOBI | diagonal blocks of itself |
SchurJacobi is the true one — it includes the elimination correction, so it
preconditions rather than the unreduced retained block:
summed over the eliminated blocks visible to — captured by a visibility index cached per sparsity pattern, so this costs rather than . Each block is inverted with the shared retry policy; a block that will not invert falls back to identity, because a preconditioner is a convergence aid and must not fail the solve.
PCG and the forcing sequence
Both PCG paths share one loop (linalg/schur/pcg.rs), starting from
— every caller solves for a Newton step, not a general system that
would benefit from a warm start.
Two stopping rules, and the second matters more. The residual rule is
but CG minimises the quadratic model , and that value is available for free from vectors already on hand:
A trust-region optimizer does not need the system solved accurately; it needs a step that reduces the model. Insisting on a small residual on an ill-conditioned burns iterations long after the model stops moving — measured on Ladybug, every solve ran the full iteration cap without the residual rule ever firing. So, following Ceres, the solve also stops on the forcing sequence with :
The shape tightens the requirement as iterations accumulate, so early Newton steps are cheap and approximate while later ones — where the optimizer is close and needs accuracy — are solved harder.
defaults to 1e-2, deliberately tighter than Ceres’s 1e-1,
because 1e-1 measured 9.7% worse RMSE on Ladybug and that loss did not recover
with more optimizer iterations. The sweep is tabulated at DEFAULT_ETA
(pcg.rs). Set with_schur_cg_q_tolerance(0.0) to disable the rule and get an
exact solve — what the cross-solver agreement tests do.
PcgResult::termination reports which rule fired: Residual,
QuadraticModel, MaxIterations (the step is truncated — worth logging) or
Breakdown.
Configuration
#![allow(unused)]
fn main() {
use apex_solver::linalg::{LinearSolverType, ExplicitSchurVariant, SchurPreconditioner};
use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;
// The BA preset: matrix-free, Schur-Jacobi, forcing sequence on.
let config = LevenbergMarquardtConfig::for_bundle_adjustment();
// Exact reduced solve instead, when the step must not depend on a tolerance.
let exact = LevenbergMarquardtConfig::for_bundle_adjustment()
.with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
.with_schur_variant(ExplicitSchurVariant::Sparse);
// Memory-bound: never form JᵀJ, accept the slower serial sweep.
let low_memory = LevenbergMarquardtConfig::for_bundle_adjustment()
.with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
.with_schur_variant(ExplicitSchurVariant::Chunked);
// Tune the iterative paths.
let tuned = LevenbergMarquardtConfig::for_bundle_adjustment()
.with_schur_preconditioner(SchurPreconditioner::SchurJacobi)
.with_schur_cg_params(200, 1e-6) // max iterations, residual tolerance
.with_schur_cg_q_tolerance(1e-2); // forcing sequence; 0.0 disables
}
Mark what to eliminate on the problem itself:
#![allow(unused)]
fn main() {
let point = problem.add_variable(ManifoldType::RN, position);
problem.mark_for_elimination(point);
}
Which to use
| Situation | Choice |
|---|---|
| Large BA (the default) | ImplicitSparseSchur — fastest on the three large BAL sets, forms neither nor |
| The step must be exact | ExplicitSparseSchur / Sparse — no tolerance dependence |
| Small problems | ExplicitSparseSchur / Sparse — direct factorization wins when is small |
| will not fit | ExplicitSparseSchur / Chunked — same answer, serial, memory-lean |
| Dense mode, few thousand DOF | ExplicitDenseSchur |
| Pose graphs | Not Schur at all — SparseCholesky |
Measured numbers for each are in Benchmarks.
Covariance estimation
Covariance::compute re-linearizes the problem at a point, forms a clean
and inverts via sparse Cholesky or dense SVD:
#![allow(unused)]
fn main() {
use apex_solver::linalg::covariance::{Covariance, CovarianceAlgorithm, CovarianceOptions};
let cov = Covariance::compute(
CovarianceOptions::new(CovarianceAlgorithm::SparseCholesky),
&problem,
&variables,
)?;
let block = cov.block(key); // dof × dof marginal in tangent space
}
The covariance is a property of the problem at a point — it never consults the optimizer’s last linear system.
Pose & Priors
factors::pose — relative constraints and anchors. No sensor model: these
express graph topology.
Throughout, is the right perturbation , its inverse , and , the right and left Jacobians of the group. The identity used everywhere below is
BetweenFactor<T>
A relative measurement between two variables of the same Lie group. Odometry edges and loop closures are both this factor, the latter usually with a robust loss.
Blocks — any T: LieGroup. Residual ,
Jacobian .
Error
With measurement ,
zero exactly when the estimated relative transform equals the measured one.
Jacobian
Write . Perturbing the second variable, , gives directly
Perturbing the first, , moves the inverse to the left of , and it has to be commuted through:
so
For SE(3), and
The implementation never writes these forms out: it chains the Jacobians that
between, compose and log report, so it stays correct for every group.
#![allow(unused)]
fn main() {
use apex_solver::factors::pose::BetweenFactor;
problem.add_residual_block_with_noise(
&[key_i, key_j],
Box::new(BetweenFactor::new(measured_relative_pose)),
Some(Box::new(HuberLoss::new(1.345)?)),
NoiseModel::from_sigmas(&[0.05, 0.05, 0.05, 0.01, 0.01, 0.02])?,
);
}
PriorFactor<T>
Anchors one variable to a known value in the tangent space. Prefer this to
Problem::fix_variable: fixed indices are honoured when the step is applied,
but the linear system still treats those coordinates as free, so variables
sharing factors with a “fixed” one are systematically under-corrected.
Blocks . Residual , Jacobian square.
Error and Jacobian
This is a BetweenFactor with an identity measurement and a constant first
argument, and is implemented as exactly that — the two share one chain and
cannot drift apart. Because the residual never touches the parameters, there is
no quaternion double-cover ambiguity.
EuclideanPriorFactor
Registration rejects any manifold but Rn — on a Lie group this would
difference raw parameters, which is not a tangent-space error. Use it to anchor
velocities, biases and intrinsics.
PoseRotationPrior / PoseTranslationPrior
Constrain one half of an SE(3) pose and leave the other free.
The translation block is , not , because the right perturbation moves the origin by in the world frame.
These are not a decomposition of PriorFactor<SE3>: the translation rows
here are the world-frame difference, whereas the prior’s are the coupled SE(3)
logarithm , which mixes rotation into translation through
. Stacking both does not reproduce the full
prior.
PoseRotationPrior is the usual rotation-only loop-closure initializer;
PoseTranslationPrior is how a GNSS position fix attaches to an SE(3) pose.
Visual
factors::visual — everything that goes through a camera.
All projection factors share a smooth cheirality penalty. A landmark behind the camera cannot be projected, but returning zero would make an invalid configuration look optimal, so they emit
with , — large enough to dominate any plausible in-image residual, small enough not to ill-condition the normal equations, and carrying a gradient that pushes the point back in front.
Two derivatives recur throughout. For a world-to-camera pose acting directly, :
For an inverted pose, , a right perturbation gives :
Both come from SE3::act, which reports them itself.
ProjectionFactor<CAM, OP>
Monocular reprojection over observations of one pose, generic over the camera model and over which blocks are optimized.
Blocks ,
selected at compile time by OP: OptimizationConfig — BundleAdjustment,
SelfCalibration, OnlyPose, OnlyLandmarks, OnlyIntrinsics,
PoseAndIntrinsics, LandmarksAndIntrinsics. Unused blocks cost nothing; the
const-generic flags remove their columns entirely.
Residual , Jacobian .
Error
Jacobian
The camera model supplies (2×3) and (2×) analytically. For a pinhole model,
Nine models are available (Pinhole, RadTan, KannalaBrandt,
DoubleSphere, Eucm, Ucm, Fov, …).
ExtrinsicProjectionFactor<CAM>
Reprojection with the camera-to-body transform as a variable. On a real rig the extrinsics are the least trustworthy part of the setup and are shared by every observation from that camera, so they belong in the graph where many observations determine them.
Blocks — residual 2, Jacobian . Both poses read the way a calibration file does: body-in-world, camera-in-body.
Error
Jacobian
With (2×6),
The factor is ,
which compose reports directly. The extrinsics block gets unchanged
because .
TimeOffsetProjectionFactor<CAM>
Reprojection with the camera-to-IMU clock offset as a variable. The two clocks differ by a constant lag ; unmodelled it is indistinguishable from an extrinsic rotation error at constant angular rate, and 20 ms at 1 m/s is 2 cm of position error.
Blocks — residual 2, Jacobian .
Error
A first-order expansion of the state to the exposure instant,
then and the same reprojection as above. The
state is SE23 because it already carries ;
is the bias-corrected gyro sample, a measurement, since the graph has no
angular rate to estimate.
Jacobian
The SE23 tangent
pushes to the exposure pose’s SE(3) tangent as
and the offset column is the exposure pose’s own tangent velocity,
Both are then chained with exactly as above. Note the offset column vanishes when and : a stationary platform cannot observe , which the tests assert directly.
StereoFactor
Rectified stereo, so disparity fixes depth in a single observation.
Blocks — residual 3, Jacobian .
Error
Jacobian
chained with and . Calibration is fixed
at construction — this is the one camera factor not generic over CameraModel.
InverseDepthFactor<CAM>
A landmark parameterized in its anchor camera by pixel and inverse depth , which stays well-conditioned for distant, low-parallax points where a Euclidean 3D point blows up.
Blocks — residual 2, Jacobian .
The anchor block’s Jacobian carries , which is what makes the parameterization behave as : the inverse depth stays finite and its derivative bounded where would not.
SmartProjectionFactor<CAM>
Structure-less: connects poses to a landmark that is never a variable. The point is re-triangulated by DLT from the current poses at every linearization, then eliminated exactly by the implicit Schur complement.
Blocks poses — residual , Jacobian . Whitens
internally; register with NoiseModel::null().
Jacobian
Writing (2×6) and (2×3),
The triangulated point satisfies the first-order optimality condition , so this dense is the total derivative of the re-triangulated residual, and
is exactly the Schur complement. Degenerate geometry (pure rotation,
rank-deficient DLT) returns a bounded penalty; status() reports which.
EssentialMatrixFactor / EssentialMatrixConstraint
2D–2D epipolar geometry over a relative pose, for initialization and loop-closure verification before any landmark exists. Points are normalized camera coordinates.
The direction normalization contributes , which is rank 2 — both factors are scale-free by construction. Only the translation direction is observable from epipolar geometry, so a graph containing only these has a one-dimensional null space; the constraint’s six rows are effectively rank 5.
DepthFactor<ONESIDED> / HomogeneousPointFactor
Both act on a homogeneous 4D landmark , the parameterization that keeps points at infinity representable.
DepthFactor — an RGB-D-style depth reading
Blocks — residual 1, Jacobian .
With ONESIDED = true (OneSidedDepthFactor) both residual and Jacobian are
zeroed when , penalizing only points that come too close —
a free-space constraint rather than a depth measurement.
HomogeneousPointFactor — a unary prior on the dehomogenized position
Blocks — residual 3, Jacobian .
Both whiten internally.
IMU
factors::imu — inertial preintegration. Two factors per group and nothing
else, so a choice here is two questions rather than a catalogue.
Preintegration
Integrating raw IMU samples between keyframes would re-integrate the whole interval every time the state changes. Preintegration instead accumulates the measurements into a relative quantity that does not depend on the states — only on the bias linearization point — so a keyframe pair can be re-evaluated in constant time.
ImuPreintegration::new(measurements, params, t0, t1, &bias_ref) accumulates
the delta , its covariance, and the bias-correction
Jacobians . Everything it produces is
gravity-free; the factors reinstate gravity when they build the
gravity-corrected state.
Two axes
Which group? se23 models a keyframe as ;
sgal3 adds a time coordinate, , making the
inter-keyframe interval an estimated quantity — pick it for time-offset or
rolling-shutter calibration, se23 otherwise.
Combined or not? This is the split GTSAM draws between ImuFactor and
CombinedImuFactor, and it decides the residual dimension.
ImuFactor | CombinedImuFactor | |
|---|---|---|
se23 | 9D, (SE23, SE23, bias) | 15D, (SE23, bias, SE23, bias) |
sgal3 | 10D, (SGal3, SGal3, bias) | 16D, (SGal3, bias, SGal3, bias) |
ImuFactor shares one bias variable across the interval and leaves its
evolution to a separate edge; CombinedImuFactor takes a bias per keyframe and
embeds the random walk in six extra residual rows. Doing both counts that
uncertainty twice.
Weighting follows the same split: the shared-bias form uses a information built from measurement noise alone, the combined form the full including the random walk.
A keyframe is a single variable on the group, not separate pose and
velocity blocks — the optimizer’s update is then a group right-plus, and the
pose/velocity coupling inertial integration produces is the group’s job. The
practical consequence is that aiding measurements attach to that state: an
SE(3)-only factor cannot connect to an SE23 variable, while
PriorFactor<SE23> and MarginalPriorFactor are generic and do.
Error
Folding gravity into the state rather than the residual is what makes the comparison a plain group right-minus. The bias correction is the first-order update of the preintegrated delta for a bias away from its linearization point,
The combined form appends (6 rows).
Jacobian
All four factors are thin wrappers over one evaluate, which returns the
residual and three tangent-space blocks. Write
for the two Jacobians
right_minus reports and for the tangent Jacobian of
right_plus. Then
The comes from differentiating with respect to the left argument: .
The bias block expands to
in the tangent order.
The lift from to the gravity-corrected state is a constant matrix, independent of the linearization point, because shares ’s rotation and shifts the rest by gravity terms only:
The combined form appends the bias-walk rows, whose Jacobian is constant:
Every above is reported by the group’s own right_plus, right_minus and
adjoint; none of it is hand-derived.
Bias evolution
The non-combined factors need a companion edge, which
factors::imu::bias builds as a BetweenFactor<Rn> with zero measurement:
#![allow(unused)]
fn main() {
use apex_solver::factors::imu::{se23, bias_random_walk, bias_random_walk_noise};
problem.add_residual_block(
&[state_i, state_j, bias],
Box::new(se23::ImuFactor::new(preintegration)),
None,
);
problem.add_residual_block_with_noise(
&[bias, bias_next],
Box::new(bias_random_walk()),
None,
bias_random_walk_noise(¶ms, dt)?,
);
}
An initial bias prior (an EuclideanPriorFactor on ) is required
for observability in every configuration.
SGal(3) and the time row
SGal(3)’s tangent carries a time coordinate, so the residual gains a row
, weighted by (with_time_sigma, default
100 µs). That row is the reason to choose SGal(3): it makes the interval a
quantity the optimizer can move.
Known limitation. SGal(3)’s group law is — the left operand’s time couples the right operand’s velocity into translation. The residual composes , so it depends on the absolute , while the preintegrated delta corresponds to . A single interval with is correct; chaining these factors across keyframes on a common absolute clock is not. Prefer the
se23factors for multi-keyframe chains until the residual is re-derived in terms of .
LiDAR
factors::lidar — scan registration. Correspondences are established upstream:
these factors evaluate one already-matched pair, so the data association is the
front end’s job.
Two graph topologies coexist deliberately. The two-pose family
(distance_field, edge, and the LidarPlaneFactor alias) registers a scan
against a reference frame with the query point baked into the factor. The
pose-and-point family (plane, point_to_point, gicp) treats the
body-frame point as a variable. Pick the one matching how your front end
parameterizes the scan.
The derivative every pose-and-point factor needs is SE3::act’s:
IcpFactor<F> — point-to-field
Aligns a query point against a distance or occupancy field defined in another
frame. Generic over F: DistanceField, which returns the field value and its
gradient at a query point.
Blocks — residual 1, Jacobian . Whitens internally with .
Error
Dividing by converts the field value into an approximate metric distance to the surface, so one is meaningful across fields of different scaling.
Jacobian
The residual and Jacobian are zeroed where the field query fails or — an undefined gradient carries no information, and a huge one is numerical noise.
LidarPlaneFactor — LOAM point-to-plane, two-pose
Not a distinct residual: it is the type alias
IcpFactor<PrecomputedPlane>, where the field is a plane through
with unit normal :
so and the Jacobian
above specializes exactly. Build one with lidar_plane_factor_isotropic.
LidarEdgeFactor — LOAM point-to-line
The only genuinely distinct LiDAR residual here.
Blocks — residual 3, Jacobian . Whitens internally.
Error
With a matched edge line through with unit direction ,
the vector rejection of the offset onto the line’s orthogonal complement. The scalar cross-product form would measure the same distance but is non-differentiable at its own zero, which is precisely where the optimizer converges.
Jacobian
The projector is constant, so
, chained with the same
blocks as IcpFactor.
PointToPlaneFactor — plane as a measurement
Blocks — residual 1, Jacobian .
Takes an external NoiseModel.
for a target plane with .
One constraint per correspondence, along the normal only — which is why three non-parallel planes are needed to fix a translation.
PoseToPointFactor
Blocks — residual 3, Jacobian .
The plain point-to-point correspondence, and the special case of GICP.
GicpFactor — plane-to-plane
Generalized ICP: the same point-to-point residual, but Mahalanobis-whitened by the combined surface covariance, so tangential directions (flat in both clouds) contribute almost nothing and the normal direction dominates. Plane-to-plane registration emerges from point-to-point with the right metric.
Blocks — residual 3, Jacobian . Whitens internally.
Error
is the symmetric inverse square root, with eigenvalues below clamped to zero so a rank-deficient covariance yields a pseudo-inverse root rather than non-finite entries.
Jacobian
is evaluated once at construction from a caller-supplied rotation hint and then held fixed, so the rotation-dependence of is dropped from the Jacobian. That is deliberate: callers rebuild correspondences (and these factors) every scan-matching iteration, so the frozen covariance never goes stale, and freezing it keeps the residual/Jacobian pair exactly consistent.
GNSS & Navigation
factors::navigation — satellite positioning and the auxiliary sensors that
bound inertial drift.
GpsFactor — position fix with a lever arm and frame transform
The receiver reports position in its frame, and the antenna is not at the body origin. Both matter: the frame transform is often unsurveyed, and the lever arm couples attitude into the position measurement.
Blocks — residual 3, Jacobian . Whitens internally.
Error
with the sensor-to-antenna lever arm in the sensor frame.
Jacobian
then scaled by . Note the attitude column is proportional to the lever arm: with a position fix says nothing about orientation.
Observability. Each pose owns three translation degrees of freedom and receives one 3D fix, so the poses can absorb any frame transform — with fully free the graph has a six-dimensional null space and a zero-cost family of solutions. Survey the frame rotation (or anchor enough poses) before estimating it.
GpsAsyncFactor — a fix that lands between keyframes
GNSS epochs rarely coincide with keyframes. This propagates the keyframe state forward with an IMU preintegration to the fix timestamp, then applies the same position error.
Blocks — residual 3, Jacobian . Whitens internally.
Error
then and the same
projection into the GNSS frame as GpsFactor.
The term is easy to lose: every preintegrated quantity is gravity-free, so gravity has to be reinstated here exactly as the IMU factors do when they build their gravity-corrected state. Omitting it biases the predicted antenna position by 0.44 m after only 0.3 s, growing quadratically.
GpsVelocityFactor
Blocks — residual 3, Jacobian .
A Doppler-derived velocity in the local tangent frame, on a plain
variable. For a raw range-rate along a satellite line of sight
use DopplerFactor instead.
PseudorangeFactor
Raw ranging to one satellite, before any position solution has been formed.
Blocks — receiver position and clock bias, the latter in metres (already range-equivalent). Residual 1, Jacobian .
The position and clock columns are separable only when the line-of-sight directions span three dimensions — the reason a fix needs four satellites, and why poor geometry is penalized rather than silently accepted.
DopplerFactor
Range rate along the same line of sight.
Blocks — residual 1, Jacobian .
The position column carries the derivative of itself,
which is small at typical satellite ranges — velocity is what this measurement really informs.
BarometricFactor
Blocks — pose and a slowly-varying altimeter bias. Residual 1, Jacobian .
The pose block is the third row of , not , because the right perturbation moves the origin by .
Height and bias are only separable if the bias is otherwise constrained — give it a prior and a random walk, or the pair drifts together.
AttitudeFactor
Constrains rotation so a known world direction maps onto the direction the sensor measures in the body frame — gravity from an accelerometer at rest, or the field from a magnetometer.
Blocks — residual 3, Jacobian .
The Jacobian is rank 2: rotation about the reference direction leaves it unchanged, so one such factor pins two of the three rotational degrees of freedom. Add a gravity factor and a magnetometer factor for a full AHRS.
Range & Bearing
factors::ranging — distance and direction measurements: UWB, radar, sonar,
acoustic angle-of-arrival, and bearing-only landmark observation.
Bearing lives here rather than under visual because a direction measurement
is not camera-specific — and keeping the two bearing residuals adjacent is what
keeps them on one SE(3) convention.
Convention. Poses are body-in-world, so the body-frame direction to a world landmark is
Getting this backwards ( instead of ) produces a residual that is self-consistent with its own Jacobian, so finite differences pass and only a hand-computed value catches it.
PosePoseRangeFactor
Distance between two poses’ origins — a UWB or acoustic baseline.
Blocks — residual 1, Jacobian .
The rotation columns are exactly zero: a range between origins carries no attitude information. Below the direction is undefined and the Jacobian is zeroed rather than divided by zero.
PosePointRangeFactor
Distance from a pose to a landmark.
Blocks — residual 1, Jacobian .
One constraint per measurement, along the line of sight only — trilateration needs three non-collinear anchors.
BearingFactor
Direction-only observation, on the unit sphere .
Blocks — residual 2, Jacobian . Whitens internally.
Error
where is an orthonormal basis for the tangent plane at is a 3-vector confined to a 2-manifold, so using it directly would give a structurally rank-2 residual with three rows — a singular information block.
Jacobian
With and the normalization derivative ,
all pre-multiplied by .
BearingRangeFactor
Bearing and range together — a radar or sonar return, or a camera plus depth.
Blocks — residual 4, Jacobian . Takes an
external NoiseModel.
The three bearing rows are the raw direction difference rather than a
tangent-plane projection, so they are structurally rank 2 — give the bearing
rows a common and let the noise model handle it, or use
BearingFactor plus PosePointRangeFactor if you need a full-rank
information block.
Bearing rows share BearingFactor’s derivatives; the range row is
PosePointRangeFactor’s.
Motion Models
factors::motion — what the platform’s own dynamics assert, with no sensor
involved.
That makes them unusually cheap and unusually easy to misapply. Each is valid only while its condition holds, so it is added by whatever detects that condition: a stationarity detector for ZUPT and ZARU, vehicle knowledge for the other two. Applied where the assumption is violated they are confidently wrong, in the way only a near-zero-noise constraint can be.
ZeroVelocityFactor — ZUPT
The platform is at rest. Applying this bounds the drift inertial integration would otherwise accumulate through a stop, at no sensor cost.
Blocks — residual 3, Jacobian .
Velocity is stored in the world frame, so the residual is it verbatim; the Jacobian block is because the group’s right perturbation moves it by .
ZeroAngularRateFactor — ZARU
While at rest, the gyroscope reads its own bias. This is what makes a stop observe the bias rather than merely stop accumulating it, so it complements ZUPT rather than duplicating it.
Blocks — residual 3, Jacobian .
with the gyro reading averaged over the stationary interval. The accelerometer half is untouched — at rest the accelerometer reads gravity, not its bias, so a stop says nothing about without an attitude reference.
NonholonomicFactor
A wheeled vehicle does not slide sideways or leave the ground: in the body frame its velocity has no lateral or vertical component.
Blocks — residual 2, Jacobian .
taking rows and . The block is what makes this informative about attitude: a heading error turns forward motion into apparent lateral slip, so the constraint corrects the heading, not just the velocity.
Two nearly-free constraints per state, and on a ground vehicle they bound the lateral drift inertial integration is worst at. They are violated by skidding, by lifting a wheel, and by any vehicle that can translate sideways.
PlanarMotionFactor
A vehicle confined to a horizontal plane: fixed height, no roll, no pitch.
Blocks — residual 3, Jacobian .
The two tilt rows are the world- components of the body’s and axes, not roll and pitch angles. They vanish on exactly the same set, but Euler extraction introduces a gimbal singularity and a discontinuity that a least-squares residual should not carry; these rows are smooth everywhere.
with and . The rotation blocks follow from and .
Three constraints per pose, removing the three least-observable degrees of freedom in a 6-DOF estimate of an indoor or road vehicle.
Marginalization
factors::marginal — summarizing variables a sliding window has dropped.
MarginalPriorFactor
When a window marginalizes old states, the eliminated joint distribution is summarized as a Gaussian over the remaining variables. This factor carries that Gaussian in linear container form: the Jacobian is a constant matrix, fixed at the linearization point where the marginal was computed.
Blocks any number, any manifolds — the factor is manifold-agnostic. Residual , Jacobian .
Error
where
- is the concatenated
local tangent of the connected variables relative to the marginal’s
linearization point , computed by a caller-supplied
local_logclosure — this is what keeps the factor independent of which manifolds it connects; - is the square-root information of the marginal, ;
- encodes the information vector, so that the quadratic reproduced is .
Where it comes from
Marginalizing a set out of a joint information matrix leaves the Schur complement over the remaining set :
Factor and set .
The catch
The Jacobian is constant, which is the whole point of a container — but it is only exact at . As the estimate drifts away from the linearization point the prior becomes progressively wrong, and worse, it is wrong in a way that looks confident. Re-marginalize and rebuild the factor when the estimate moves far from .
The partial-pose priors that often accompany marginalization live in
factors::pose: they anchor a pose for loop-closure
initialization rather than summarize eliminated variables.
Visualization with Rerun
The visualization feature streams poses, landmarks, cost curves, damping
trajectories and Hessian sparsity patterns to a live
Rerun viewer while the optimizer runs.
[dependencies]
apex-solver = { version = "1.5.0", features = ["visualization"] }
#![allow(unused)]
fn main() {
use apex_solver::observers::{RerunObserver, VisualizationConfig};
let config = VisualizationConfig::new()
.with_show_cameras(true)
.with_show_landmarks(true)
.with_show_plots(true);
let mut solver = LevenbergMarquardt::with_config(lm_config);
solver.add_observer(RerunObserver::with_config(true, config)?);
let result = solver.optimize(&mut problem)?;
}
Start the viewer first (rerun on the command line or rerun::Session), then
run your binary. Every iteration appears live: camera frustums, landmark points,
the cost curve, and the sparsity pattern of the current Hessian.
The visualize_optimization and visualize_graph_file examples show complete
setups for bundle adjustment and pose graphs respectively:
cargo run --release --example visualize_optimization --features visualization
Benchmarks & Performance
Headline numbers (Apple M4, release profile, median of 5 runs; methodology and
full tables in the repository’s
doc/performance.md):
timing covers optimize() only; costs are computed by the harness directly
from the input files so values are comparable across implementations.
Pose graph optimization (LM)
| dataset | vertices | edges | apex time | final χ² |
|---|---|---|---|---|
| ring | 434 | 459 | ~4 ms | 2.2e-2 |
| M3500 | 3 500 | 5 453 | ~60 ms | 1.5 |
| city10000 | 10 000 | 20 687 | ~110 ms | 4.4 |
| sphere2500 | 2 500 | 4 949 | ~190 ms | 21.3 |
| parking-garage | 1 661 | 6 275 | ~85 ms | 0.62 |
| torus3D | 5 000 | 9 048 | ~930 ms | 236 |
| cubicle | 5 750 | 16 869 | ~990 ms | 1 692 |
Bundle adjustment (LM + iterative Schur, reprojection RMSE)
| dataset | cameras | points | observations | time | final RMSE |
|---|---|---|---|---|---|
| Trafalgar | 257 | 65 132 | 225 911 | ~4 s | 0.81 px |
| Dubrovnik | 356 | 226 730 | 1 255 268 | ~48 s | 0.79 px |
| Ladybug | 1 723 | 156 502 | 678 718 | ~77 s | 0.88 px |
| Venice | 1 778 | 993 923 | 5 001 946 | ~56 s | 0.75 px |
Reproducing
cargo bench --bench odometry_pose_benchmark # pose graphs (Rust + C++ solvers)
APEX_BENCH_RUST_ONLY=1 cargo bench --bench bundle_adjustment_benchmark # apex rows only
bash benches/tools/run_repeated.sh odometry_pose_benchmark 5 # N repeats, archived CSVs
Kernel-level micro-benchmarks (normal-equation formation, assembly, PCG) live
in benches/micro_kernels.rs and run under Criterion:
cargo bench --bench micro_kernels
Migrating from 1.4
1.5.0 changes the public API. Code written against 1.4.0 will not compile
until you make the edits below. Full detail in the
changelog.
1. PriorFactor is a tangent-space anchor
PriorFactor now anchors in the manifold’s tangent space,
, and is
generic over the manifold — no quaternion double-cover ambiguity, no dropped
rotation–translation coupling, correct SE(2) angle wrap. The old ambient
parameter-space factor is renamed EuclideanPriorFactor and is restricted to
Rn variables at registration (anything else returns a DimensionMismatch
error). Struct-literal construction is gone:
#![allow(unused)]
fn main() {
// 1.4.0 — ambient
problem.add_residual_block(&[k], Box::new(PriorFactor { data: pose7 }), loss);
// 1.5.0 — tangent anchor on SE(3)
problem.add_residual_block(&[k], Box::new(PriorFactor::<SE3>::new(prior_pose)), loss);
}
For the old behaviour on an Rn variable, use
EuclideanPriorFactor::new(data).
2. Feature flags: rosbag, download, cli
apex-io’s rosbag feature is now opt-in (it was built unconditionally).
Depending on apex-io no longer compiles rusqlite (bundled SQLite), mcap,
zstd, lz4_flex, serde_norway, byteorder, hex. Bag I/O users must opt in:
apex-io = { version = "0.4", features = ["rosbag"] }
# via the solver crate:
apex-solver = { version = "1.5", features = ["rosbag"] }
download (dataset fetching) stays on by default; --no-default-features
disables it, in which case the ensure_*_dataset helpers only serve
already-downloaded files. clap is behind the default-on cli feature in both
crates; the apex-solver bins/examples likewise require cli. The bag_*
binaries need --features rosbag, download_datasets needs
--features download.
3. LinearSolver::solve_augmented_equation takes &Damping
The augmented system is now with
— Ceres’
LevenbergMarquardtStrategy. Custom LinearSolver implementations must update
the signature:
#![allow(unused)]
fn main() {
// 1.4.0
solver.solve_augmented_equation(&residuals, &jacobian, lambda)?;
// 1.5.0 — same numerics as the old uniform λI
solver.solve_augmented_equation(&residuals, &jacobian, &Damping::identity(lambda))?;
}
4. Schur solver renames
The Schur solvers were consolidated around explicit/implicit names mirroring Ceres. Old names are gone outright — no deprecated aliases:
| 1.4.0 | 1.5.0 |
|---|---|
SparseSchurComplementSolver | ExplicitSparseSchur |
IterativeSchurSolver | ImplicitSparseSchur |
| — | ExplicitDenseSchur (new) |
SchurVariant | ExplicitSchurVariant (Sparse, Iterative, Chunked) |
LinearSolverType::SparseSchurComplement | LinearSolverType::ExplicitSparseSchur |
SchurPartition, EliminatedBlocks, SchurOrdering and SchurPreconditioner
moved to the top-level src/linalg/schur/ module (previously under
src/linalg/sparse/). LinearSolverType gained ImplicitSparseSchur and
ExplicitDenseSchur discriminants; the old SchurVariant::Iterative
special-case is gone.
5. for_bundle_adjustment selects ImplicitSparseSchur
LevenbergMarquardtConfig::for_bundle_adjustment now selects the matrix-free
implicit Schur solver instead of ExplicitSparseSchur — 2.2× faster across the
four BAL datasets. The step is inexact by construction; restore the exact
reduced solve with:
#![allow(unused)]
fn main() {
config.with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
}
Numerics changed everywhere else too: Levenberg-Marquardt now damps with
from (was from ),
and step acceptance in both LM and Dog Leg is gated on
min_relative_decrease. Iterates change on every problem. To restore the old
behaviour:
#![allow(unused)]
fn main() {
LevenbergMarquardtConfig::new().with_diagonal_bounds(1.0, 1.0).with_damping(1e-3)
}
Mathematical Cookbooks (workspace crates)
The deep mathematical derivations live with the crates they document, as mdBook cookbooks rendered with KaTeX:
- apex-manifolds cookbook — conventions, exponential/log maps, Jacobians and adjoints for SO(2), SO(3), SE(2), SE(3), SE₂(3), SGal(3), Sim(3) and ℝⁿ.
- apex-camera-models cookbook — projection and unprojection derivations for the supported camera models.
- apex-io cookbook — file-format details for G2O, TORO and BAL.
The published site (this one) aggregates them:
- apex-manifolds cookbook (after the Pages deployment)
- apex-camera-models cookbook
- apex-io cookbook