This document details backend optimization strategies, focusing on manual implementation of the non-linear least squares solver. We address common issues like the H (Hessian) matrix being rank-deficient and discuss methods for constructing and solving the linear system efficiently.
Non-linear Least Squares Solver Flow
Handling Rank-Deficient H Matrix
Two primary approaches exist to handle a rank-deficient H matrix:
- Levenberg-Marquardt (LM) Algorithm: Introduce a damping factor to ensure the matrix is full-rank. While this allows for a solution, the result might drift towards the null space. Relative pose relationships remain consistent, but the overall solution might require an additional transformation to align with the ground truth.
- Prior Constraints: Incorporate prior information to enhance system observability. Libraries like g2o and Ceres often add an identity matrix to the information matrix of the first pose (
H[11] += I). This is analogous to the LM approach, differing in the additive term (Ivs.μI). Crucially, this enforcesI*Δx=0, preventing the first pose's incremental change (Δx) from being updated.
// Illustrative example for fixing vertices (equivalent to solution 3)
// All Pose vertices
std::vector<std::shared_ptr<VertexPose>> vertexCams_vec;
for (size_t i = 0; i < cameras.size(); ++i) {
auto vertexCam = std::make_shared<VertexPose>();
Eigen::VectorXd pose(7);
// Initialize pose with translation and quaternion
pose << cameras[i].twc, cameras[i].qwc.x(), cameras[i].qwc.y(), cameras[i].qwc.z(), cameras[i].qwc.w();
vertexCam->SetParameters(pose);
// Uncomment to fix the first two poses
// if (i < 2) {
// vertexCam->SetFixed();
// }
problem.AddVertex(vertexCam);
vertexCams_vec.push_back(vertexCam);
}
H Matrix Construction
The process of building the H matrix involves several steps:
Determining Dimensions
- Dimension Calculation: Define the total dimension of optimization variables
n = 6p + 3l, wherepis the number of poses andlis the number of landmarks. Determine the residual dimensionm. For each residual, compute the JacobianJ(dimensionsm_i * n). This allows for computing individualHblocks (n * n). The finalHmatrix is formed by accumulating these blocks from all residuals.
The total dimension ordering_generic_ represents the sum of LocalDimension() for all optimization variables, which dictates the size of the H matrix.
// Calculate total dimension of optimization variables for H matrix construction
void Problem::SetOrdering() {
ordering_poses_ = 0;
ordering_generic_ = 0;
ordering_landmarks_ = 0;
int pose_dimension_count = 0; // Debugging counter
// Vertices are stored in a map, ordered by ID
for (const auto& vertex_pair : verticies_) {
auto vertex = vertex_pair.second;
ordering_generic_ += vertex->LocalDimension(); // Total dimension of all optimizable variables
if (IsPoseVertex(vertex)) {
pose_dimension_count += vertex->LocalDimension();
}
if (problemType_ == ProblemType::SLAM_PROBLEM) {
AddOrderingSLAM(vertex); // Separate ordering for SLAM problems
}
if (IsPoseVertex(vertex)) {
std::cout << "Vertex ID: " << vertex->Id() << ", Ordering ID: " << vertex->OrderingId() << std::endl;
}
}
if (problemType_ == ProblemType::SLAM_PROBLEM) {
// Adjust landmark ordering to appear after poses
ulong total_pose_dimension = ordering_poses_;
for (auto const& [id, landmarkVertex] : idx_landmark_vertices_) {
landmarkVertex->SetOrderingId(landmarkVertex->OrderingId() + total_pose_dimension);
}
}
}
ordering_poses_ tracks the starting index for pose variables within the H matrix. The AddOrderingSLAM function manages this by assigning sequential ordering IDs to pose and landmark vertices and sorting them into idx_pose_vertices_ and idx_landmark_vertices_ respectively.
void Problem::AddOrderingSLAM(const std::shared_ptr<myslam::backend::Vertex>& v) {
if (IsPoseVertex(v)) {
v->SetOrderingId(ordering_poses_);
idx_pose_vertices_.insert({v->Id(), v});
ordering_poses_ += v->LocalDimension(); // Accumulate pose dimensions (e.g., 6 for SE(3))
} else if (IsLandmarkVertex(v)) {
v->SetOrderingId(ordering_landmarks_);
ordering_landmarks_ += v->LocalDimension(); // Accumulate landmark dimensions (e.g., 3 for XYZ)
idx_landmark_vertices_.insert({v->Id(), v});
}
}
Building the Hessian Matrix
The Hessian matrix is constructed by iterating through each edge (representing a constraint or measurement).
For an edge connecting two vertices (a binary edge), we retrieve its Jacobian matrices with respect to each connected vertex. The code avoids direct multiplication of J^T * Information * J by calculating smaller Jacobian blocks and placing them into the correct positions in the global H matrix using indices derived from the vertices' ordering IDs.
void Problem::MakeHessian() {
TicToc timer_hessian;
ulong matrix_size = ordering_generic_; // Total dimension of optimization variables
MatXX H(MatXX::Zero(matrix_size, matrix_size)); // Hessian matrix
VecX b(VecX::Zero(matrix_size)); // Residual vector (b)
for (auto const& [edge_id, edge] : edges_) {
edge->ComputeResidual();
edge->ComputeJacobians();
const auto& jacobians = edge->Jacobians();
const auto& verticies = edge->Verticies();
// Ensure Jacobian count matches vertex count for the edge
assert(jacobians.size() == verticies.size());
// Update Hessian and residual vector
for (size_t i = 0; i < verticies.size(); ++i) {
auto vertex_i = verticies[i];
if (vertex_i->IsFixed()) continue; // Skip fixed vertices
const auto& jacobian_i = jacobians[i];
ulong index_i = vertex_i->OrderingId();
ulong dimension_i = vertex_i->LocalDimension();
// Calculate J_i^T * Information for residual update
MatXX JtW = jacobian_i.transpose() * edge->Information();
// Update Hessian blocks
for (size_t j = i; j < verticies.size(); ++j) {
auto vertex_j = verticies[j];
if (vertex_j->IsFixed()) continue;
const auto& jacobian_j = jacobians[j];
ulong index_j = vertex_j->OrderingId();
ulong dimension_j = vertex_j->LocalDimension();
assert(vertex_j->OrderingId() != -1);
MatXX hessian_block = JtW * jacobian_j;
// Add to upper-left block
H.block(index_i, index_j, dimension_i, dimension_j).noalias() += hessian_block;
// Add to lower-right block for symmetry (if not diagonal)
if (j != i) {
H.block(index_j, index_i, dimension_j, dimension_i).noalias() += hessian_block.transpose();
}
}
// Update residual vector 'b'
b.segment(index_i, dimension_i).noalias() -= JtW * edge->Residual();
}
}
Hessian_ = H;
b_ = b;
total_hessian_cost_ += timer_hessian.toc();
// Incorporate prior constraints if they exist
if (prior_error_.rows() > 0) {
// Adjust residual for prior
b_prior_ -= H_prior_ * delta_x_.head(ordering_poses_);
}
// Add prior Hessian to the top-left block of the main Hessian
Hessian_.topLeftCorner(ordering_poses_, ordering_poses_) += H_prior_;
// Add prior residuals to the top part of the main residual vector
b_.head(ordering_poses_) += b_prior_;
// Initialize the delta_x (update vector) to zero
delta_x_ = VecX::Zero(matrix_size);
}
The code uses H.block(index_i, index_j, dim_i, dim_j).noalias() += hessian; to efficiently update the Hessian. For symmetric matrices, calculating the upper triangle is sufficient; the lower triangle is filled by transposing the computed block.
The residual vector b is updated within the outer loop for each vertex i. Note that b is only computed once per vertex i in the outer loop.
Initializing LM Damping Parameter (μ)
The initial damping parameter μ_0 for the LM algorithm is set using the maximum diagonal element of the Hessian:
μ_0 = τ * max{(J^T * J)_ii}
void Problem::ComputeLambdaInitLM() {
// ... (existing code for finding max diagonal element) ...
double tau = 1e-5; // Damping factor
currentLambda_ = tau * maxDiagonal;
}
Solving the Linear System
After constructing H and b, and setting the LM parameter, the linear system H * Δx = b can be solved.
Non-SLAM Problems: Matrix Inversion
For dense H matrices typical in non-SLAM problems, direct matrix inversion is feasible.
if (problemType_ == ProblemType::GENERIC_PROBLEM) {
// Non-SLAM problems: Solve directly using inversion
MatXX H_augmented = Hessian_;
// Add damping factor to the diagonal
for (ulong i = 0; i < matrix_size; ++i) {
H_augmented(i, i) += currentLambda_;
}
// Solve for delta_x
// delta_x_ = PCGSolver(H_augmented, b_, H_augmented.rows() * 2); // PCG if preferred
delta_x_ = Hessian_.inverse() * b_; // Direct inversion
}
SLAM Problems: Schur Complement
SLAM problems often result in sparse H matrices. Utilizing this sparsity, the Schur complement method is employed for efficient marginalization and solving.
Given the partitioned system:
$$ \begin{bmatrix} H ext{\textsubscript{pp}} & H ext{\textsubscript{pm}} \ H ext{\textsubscript{mp}} & H ext{\textsubscript{mm}} \end{bmatrix} \begin{bmatrix} \Delta x ext{\textsubscript{p}}^{\ast} \ \Delta x ext{\textsubscript{m}}^{\ast} \end{bmatrix} = \begin{bmatrix} b ext{\textsubscript{p}} \ b ext{\textsubscript{m}} \end{bmatrix} $$
where p denotes poses and m denotes landmarks:
① Partitioning by Pose and Landmark
The H matrix dimensions are (p + m) * (p + m) and Δx is (p + m) * 1.
ordering_poses_: Total dimension of pose variables (e.g.,6 * num_poses).ordering_landmarks_: Total dimension of landmark variables (e.g.,3 * num_landmarks).
// SLAM problems use Schur complement for solving
int pose_block_size = ordering_poses_; // Size of the pose block (H_pp, H_pm, H_mp)
int landmark_block_size = ordering_landmarks_; // Size of the landmark block (H_mm)
// Extract blocks for Schur complement calculation
// H_mm: Landmark-Landmark block
MatXX H_mm = Hessian_.block(pose_block_size, pose_block_size, landmark_block_size, landmark_block_size);
// H_pm: Pose-Landmark block
MatXX H_pm = Hessian_.block(0, pose_block_size, pose_block_size, landmark_block_size);
// H_mp: Landmark-Pose block
MatXX H_mp = Hessian_.block(pose_block_size, 0, landmark_block_size, pose_block_size);
// Extract residual blocks
VecX b_p = b_.segment(0, pose_block_size);
VecX b_m = b_.segment(pose_block_size, landmark_block_size);
② Schur Complement
The Schur complement is derived to eliminate landmark variables, resulting in a smaller system for poses.
-
Invert
H_mm: SinceH_mmis typically diagonal (especially with inverse depth parameterization), its inverse can be computed efficiently by inverting diagonal blocks or individual elements.// Invert the landmark-landmark block (H_mm) // Assuming H_mm is block-diagonal or diagonal for efficiency MatXX H_mm_inv(MatXX::Zero(landmark_block_size, landmark_block_size)); for (auto const& [id, landmarkVertex] : idx_landmark_vertices_) { // Adjust index to be relative to the landmark block int idx_relative = landmarkVertex->OrderingId() - pose_block_size; int dim = landmarkVertex->LocalDimension(); // Dimension of this landmark's parameters (e.g., 3 for XYZ) // Invert the corresponding block H_mm_inv.block(idx_relative, idx_relative, dim, dim) = H_mm.block(idx_relative, idx_relative, dim, dim).inverse(); } -
Compute Pose Update Equation: The Schur complement reduces the system to:
$$ \left( H ext{\textsubscript{pp}} - H ext{\textsubscript{pm}} H ext{\textsubscript{mm}}^{-1} H ext{\textsubscript{mp}} \right) \Delta x ext{\textsubscript{p}}^{\ast} = b ext{\textsubscript{p}} - H ext{\textsubscript{pm}} H ext{\textsubscript{mm}}^{-1} b ext{\textsubscript{m}} $$
// Compute the Schur complement for the pose block (H_pp_schur_) MatXX temp_H_pm = H_pm * H_mm_inv; H_pp_schur_ = Hessian_.block(0, 0, pose_block_size, pose_block_size) - temp_H_pm * H_mp; // Compute the corresponding residual vector for poses (b_pp_schur_) b_pp_schur_ = b_p - temp_H_pm * b_m;
③ Solve Linear System
-
Solve for Pose Updates (
Δx_p^*): Solve the reduced system for the pose increments.VecX delta_x_p(VecX::Zero(pose_block_size)); // Add LM damping to the pose block's Schur complement for (ulong i = 0; i < pose_block_size; ++i) { H_pp_schur_(i, i) += currentLambda_; } // Solve the reduced linear system for poses // Using PCG solver for demonstration, though direct solve might be faster for small systems int iterations = H_pp_schur_.rows() * 2; // Example iteration count for PCG delta_x_p = PCGSolver(H_pp_schur_, b_pp_schur_, iterations); // Store the pose updates delta_x_.head(pose_block_size) = delta_x_p; -
Solve for Landmark Updates (
Δx_m^*): Back-substitute the pose updates to find the landmark increments using the original equations:H_mm * Δx_m^* = b_m - H_mp * Δx_p^*// Solve for landmark updates using the calculated pose updates VecX delta_x_landmarks(landmark_block_size); delta_x_landmarks = H_mm_inv * (b_m - H_mp * delta_x_p); // Store the landmark updates delta_x_.tail(landmark_block_size) = delta_x_landmarks;
Marginalization Test
Marginalization is a technique to remove variables from the optimization, incorporating their effect as a prior. This section demonstrates moving variables to the bottom-right corner of the H matrix, simulating marginalization.
Moving Marginalized Variables
To isolate a variable for marginalization, it's moved to the bottom-right block of the H matrix.
-
Move Row
ito the End: The row containing the variable is extracted and placed at the matrix's end.// Example: Move row 'idx' of dimension 'dim' to the end of a 'reserve_size' matrix int idx = 1; // Index of the variable to marginalize int dim = 1; // Dimension of the variable int reserve_size = 3; // Total dimension of variables // Move row 'idx' to the end Eigen::MatrixXd temp_rows = H_marg.block(idx, 0, dim, reserve_size); Eigen::MatrixXd temp_botRows = H_marg.block(idx + dim, 0, reserve_size - idx - dim, reserve_size); H_marg.block(idx, 0, reserve_size - idx - dim, reserve_size) = temp_botRows; H_marg.block(reserve_size - dim, 0, dim, reserve_size) = temp_rows; -
Move Column
ito the Right: Similarly, the corresponding column is moved to the rightmost position.// Move column 'idx' to the right end Eigen::MatrixXd temp_cols = H_marg.block(0, idx, reserve_size, dim); Eigen::MatrixXd temp_rightCols = H_marg.block(0, idx + dim, reserve_size, reserve_size - idx - dim); H_marg.block(0, idx, reserve_size, reserve_size - idx - dim) = temp_rightCols; H_marg.block(0, reserve_size - dim, reserve_size, dim) = temp_cols;
Marginalization via Schur Complement
This process effectively computes a prior Hessian (H_prior) representing the influence of the marginalized variables on the remaining ones.
/// Perform Schur complement for marginalization
double epsilon = 1e-8;
int marginalized_dim = dim;
int remaining_dim = reserve_size - dim; // Dimension of remaining variables
// Extract the block corresponding to marginalized variables (Amm)
Eigen::MatrixXd Amm = 0.5 * (H_marg.block(remaining_dim, remaining_dim, marginalized_dim, marginalized_dim) + H_marg.block(remaining_dim, remaining_dim, marginalized_dim, marginalized_dim).transpose());
// Compute inverse of Amm (e.g., using Eigen's SelfAdjointEigenSolver for robustness)
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> saes(Amm);
Eigen::MatrixXd Amm_inv = saes.eigenvectors() * Eigen::VectorXd(
(saes.eigenvalues().array() > epsilon).select(saes.eigenvalues().array().inverse(), 0)).asDiagonal() *
saes.eigenvectors().transpose();
// Extract other blocks for Schur complement calculation
// Arm: Remaining-Marginalized block
Eigen::MatrixXd Arm = H_marg.block(0, remaining_dim, remaining_dim, marginalized_dim);
// Amr: Marginalized-Remaining block
Eigen::MatrixXd Amr = H_marg.block(remaining_dim, 0, marginalized_dim, remaining_dim);
// Arr: Remaining-Remaining block
Eigen::MatrixXd Arr = H_marg.block(0, 0, remaining_dim, remaining_dim);
// Compute the prior Hessian for the remaining variables
Eigen::MatrixXd temp_Arm = Arm * Amm_inv;
Eigen::MatrixXd H_prior = Arr - temp_Arm * Amr;
Summary: Preventing Optimization Drift
Adding Prior Constraints
Impose strong prior constraints on specific variables. This is achieved by assigning a very large weight to the corresponding information matrix in a prior edge, effectively setting the update Δx for that variable to zero.
Fixing Poses or Landmarks
Directyl fix certain camera poses or landmark positions to prevent them from being optimized. For monocular Bundle Adjustment (BA) problems, common strategies include fixing one camera pose and one feature, or fixing two camera poses. This ensures that the optimization state does not drift uncontrollably.
// Example of fixing vertices during problem construction
if (i < 2) { // Fix the first two camera poses
vertexCam->SetFixed();
}
Supporting Structures
Frame Structure
Represents a single camera frame, storing its pose (rotation Rwc and translation twc, quaternion qwc) and observed features.
/*
* Frame: Stores pose and observations for each camera frame.
*/
struct Frame {
Frame(Eigen::Matrix3d R, Eigen::Vector3d t) : Rwc(R), qwc(R), twc(t) {};
Eigen::Matrix3d Rwc; // Rotation from camera to world
Eigen::Quaterniond qwc; // Quaternion representation of Rwc
Eigen::Vector3d twc; // Translation of camera center in world coordinates
// Map of feature ID to its observed 2D projection in this frame
std::unordered_map<int, Eigen::Vector3d> featurePerId;
};
Generating Synthetic Data
This function simulates camera poses and 3D landmark positions in a world frame, along with corresponding feature observations in each frame. This is useful for testing the optimization pipeline.
/*
* Generate synthetic world-frame data: camera poses, feature points, and observations per frame.
*/
void GetSimDataInWordFrame(std::vector<Frame>& cameraPoses, std::vector<Eigen::Vector3d>& points) {
int num_features = 20; // Number of features, assuming all are visible in each frame
int num_poses = 3; // Number of camera poses
double radius = 8.0;
for (int n = 0; n < num_poses; ++n) {
double theta = n * 2.0 * M_PI / (num_poses * 4.0); // Along a 1/4 circle arc
// Rotation around the Z-axis
Eigen::Matrix3d R;
R = Eigen::AngleAxisd(theta, Eigen::Vector3d::UnitZ());
Eigen::Vector3d t = Eigen::Vector3d(
radius * cos(theta) - radius,
radius * sin(theta),
1.0 * sin(2.0 * theta)
);
cameraPoses.push_back(Frame(R, t));
}
// Generate random 3D points for features
std::default_random_engine generator;
// Noise model based on 2 pixels / focal length (assuming focal length = 1)
std::normal_distribution<double> noise_pdf(0., 2.0 / 1000.0);
for (int j = 0; j < num_features; ++j) {
std::uniform_real_distribution<double> xy_rand(-4.0, 4.0);
std::uniform_real_distribution<double> z_rand(4.0, 8.0);
Eigen::Vector3d Pw(xy_rand(generator), xy_rand(generator), z_rand(generator));
points.push_back(Pw);
// Generate observations for each frame
for (int i = 0; i < num_poses; ++i) {
// Project 3D point into camera frame: Pc = Rwc * (Pw - twc)
Eigen::Vector3d Pc = cameraPoses[i].Rwc.transpose() * (Pw - cameraPoses[i].twc);
Pc = Pc / Pc.z(); // Normalize to image plane coordinates
// Add noise
Pc[0] += noise_pdf(generator);
Pc[1] += noise_pdf(generator);
cameraPoses[i].featurePerId.insert({j, Pc});
}
}
}
Binary Edge: Pose and Landmark Optimization Test
This section demonstrates setting up and solving a SLAM problem with binary edges (connecting poses and landmarks).
// 1. Initialize the problem
Problem problem(Problem::ProblemType::SLAM_PROBLEM);
// 2. Create Pose vertices
std::vector<std::shared_ptr<VertexPose> > vertexCams_vec;
for (size_t i = 0; i < cameras.size(); ++i) {
auto vertexCam = std::make_shared<VertexPose>();
Eigen::VectorXd pose(7);
// Initialize pose with translation and quaternion (qcw is camera-to-world)
pose << cameras[i].twc, cameras[i].qcw.x(), cameras[i].qcw.y(), cameras[i].qcw.z(), cameras[i].qcw.w();
vertexCam->SetParameters(pose);
problem.AddVertex(vertexCam);
vertexCams_vec.push_back(vertexCam);
}
// 3. Create Landmark vertices
std::vector<std::shared_ptr<VertexPointXYZ>> allPoints;
for (size_t i = 0; i < points.size(); ++i) {
Eigen::Vector3d Pw = points[i]; // Assuming initial frame (frame 0) has ground truth points
auto verterxPoint = std::make_shared<VertexPointXYZ>();
verterxPoint->SetParameters(Pw);
problem.AddVertex(verterxPoint);
allPoints.push_back(verterxPoint);
// 4. Create Reprojection Error edges (binary edges)
// Start from the second frame (index 1) for observations
for (size_t j = 1; j < cameras.size(); ++j) {
Eigen::Vector3d observed_pt = cameras[j].featurePerId.at(i);
// Create a reprojection error edge connecting landmark and pose
auto edge = std::make_shared<EdgeReprojectionXYZ>(observed_pt);
std::vector<std::shared_ptr<Vertex> > edge_vertices;
edge_vertices.push_back(verterxPoint); // Landmark vertex
edge_vertices.push_back(vertexCams_vec[j]); // Pose vertex
edge->SetVertex(edge_vertices);
problem.AddEdge(edge);
}
}
problem.Solve(5); // Solve the optimization problem for 5 iterations
std::cout << "------------ Pose Translation After Optimization ----------------" << std::endl;
for (size_t i = 0; i < vertexCams_vec.size(); ++i) {
std::cout << "Translation (optimized) Frame " << i << ": " << vertexCams_vec[i]->Parameters().head(3).transpose()
<< " || Ground Truth: " << cameras[i].twc.transpose() << std::endl;
}
The results show that the first frame's pose remains stable, and the optimized values are close to the ground truth. The exact behavior can depend on the specific configuration and data.