Build two-dimensional laser SLAM from scratch -- code implementation of back-end optimization based on ceres

Keywords: Algorithm Back-end Autonomous vehicles

In the last article, we analyzed how to use G 2O to optimize the pose map

Because g2o naturally optimizes the pose graph, it is very suitable for the interface of karto's pose graph. It only needs to assign the corresponding vertices and constraints respectively

In this article, let's take a look at another commonly used optimization library Ceres solver

1. Introduction to Ceres

Ceres solver is a library developed by Google for nonlinear optimization. It is widely used in laser SLAM and V-SLAM optimization, such as Cartographer, VINS and so on

You can directly go to its official website to see the tutorials for learning ceres. The library documents produced by Google are great. I won't say more here
http://ceres-solver.org/tutorial.html

At present, many online tutorials and articles are the official website of translation. It's better to see first-hand materials

I made a note when reading the official documents before. Interested students can also have a look

ceres notes
https://blog.csdn.net/tiancailx/article/details/117601117

2. Code explanation of backend optimization based on ceres

Next, let's take a look at how to use ceres to solve back - end optimization

The first is the header file. The header file is basically the same, that is, the inheritance of the interface

2.1 adding nodes to pose diagram

Since ceres is not specially used for pose graph optimization, the essence of ceres is to solve the least square problem

At present, the essence of pose graph optimization in slam problem depends on least squares, so pose graph optimization can be transformed into least squares problem

There is no concept of nodes and edges in ceres, so in AddNode function, nodes can be added to g2o directly, unlike g2o. Here, the nodes are saved first, calculated when optimized, and then transferred to ceres

Because the data type in ceres is double, format conversion is performed when saving

// Add node
void CeresSolver::AddNode(karto::Vertex<karto::LocalizedRangeScan> *pVertex)
{
  karto::Pose2 pose = pVertex->GetObject()->GetCorrectedPose();
  int pose_id = pVertex->GetObject()->GetUniqueId();
  Pose2d pose2d;
  pose2d.x = pose.GetX();
  pose2d.y = pose.GetY();
  pose2d.yaw_radians = pose.GetHeading();
  poses_[pose_id] = pose2d;
}

struct Pose2d
{
  double x;
  double y;
  double yaw_radians;
};

2.2 adding edges (constraints) to pose diagrams

The implementation of AddConstraint function is similar to that of AddNode function

Because the edge cannot be added to ceres directly, the data format is converted first, and then the edge is saved

The data saved here are the ID of two nodes and the pose transformation between two nodes

When saving the covariance matrix of pose, the inverse operation is carried out, so the information matrix is saved

// Add constraint
void CeresSolver::AddConstraint(karto::Edge<karto::LocalizedRangeScan> *pEdge)
{
  karto::LocalizedRangeScan *pSource = pEdge->GetSource()->GetObject();
  karto::LocalizedRangeScan *pTarget = pEdge->GetTarget()->GetObject();
  karto::LinkInfo *pLinkInfo = (karto::LinkInfo *)(pEdge->GetLabel());

  karto::Pose2 diff = pLinkInfo->GetPoseDifference();
  karto::Matrix3 precisionMatrix = pLinkInfo->GetCovariance().Inverse();
  Eigen::Matrix<double, 3, 3> info;
  info(0, 0) = precisionMatrix(0, 0);
  info(0, 1) = info(1, 0) = precisionMatrix(0, 1);
  info(0, 2) = info(2, 0) = precisionMatrix(0, 2);
  info(1, 1) = precisionMatrix(1, 1);
  info(1, 2) = info(2, 1) = precisionMatrix(1, 2);
  info(2, 2) = precisionMatrix(2, 2);
  Eigen::Vector3d measurement(diff.GetX(), diff.GetY(), diff.GetHeading());

  Constraint2d constraint2d;
  constraint2d.id_begin = pSource->GetUniqueId();
  constraint2d.id_end = pTarget->GetUniqueId();
  constraint2d.x = measurement(0);
  constraint2d.y = measurement(1);
  constraint2d.yaw_radians = measurement(2);
  constraint2d.information = info;
  constraints_.push_back(constraint2d);
}

struct Constraint2d
{
  int id_begin;
  int id_end;

  double x;
  double y;
  double yaw_radians;

  Eigen::Matrix3d information;
};

2.3 optimization solution

And save the optimized pose

The data was saved before, and the real calculation is in these two functions, namely builduptimizationproblem() and SolveOptimizationProblem()

// Optimization solution
void CeresSolver::Compute()
{
  corrections_.clear();

  ROS_INFO("[ceres] Calling ceres for Optimization");
  ceres::Problem problem;
  BuildOptimizationProblem(constraints_, &poses_, &problem);
  SolveOptimizationProblem(&problem);
  ROS_INFO("[ceres] Optimization finished\n");

  for (std::map<int, Pose2d>::const_iterator pose_iter = poses_.begin(); pose_iter != poses_.end(); ++pose_iter)
  {
    karto::Pose2 pose(pose_iter->second.x, pose_iter->second.y, pose_iter->second.yaw_radians);
    corrections_.push_back(std::make_pair(pose_iter->first, pose));
  }
}

2.4 modeling

2.4.1 least squares problem

Explain the code briefly

First, the angle update method is defined through the AngleLocalParameterization::Create() function

Note that the Ceres:: localparameterization * angle_ local_ The ownership of the parameterization pointer is given to the problem in the problem - > setparameterization() function, that is, angle_local_parameterization will be destroyed when the problem is destructed

Note that this pointer is placed on the top of the for loop. It can be seen that the same pointer can be passed into AddResidualBlock() and SetParameterization() multiple times. Only after the problem is destructed, this pointer is destroyed and can no longer be used

When I first wrote the code, I used angle_ local_ The parameter pointer is placed in the member variable of the class, and it is new only once in the constructor. When the second optimization is performed, the program crashes. It is worth noting

Then, the CostFunction is constructed through PoseGraph2dErrorTerm::Create()

Then, the existing constraint information is added to the optimization problem through AddResidualBlock(), and the update method of angle value is specified

Finally, the pose of the first node is set to be fixed without optimization and change

void CeresSolver::BuildOptimizationProblem(const std::vector<Constraint2d> &constraints, std::map<int, Pose2d> *poses,
                                           ceres::Problem *problem)
{
  if (constraints.empty())
  {
    std::cout << "No constraints, no problem to optimize.";
    return;
  }

  ceres::LocalParameterization *angle_local_parameterization = AngleLocalParameterization::Create();

  for (std::vector<Constraint2d>::const_iterator constraints_iter = constraints.begin();
       constraints_iter != constraints.end(); ++constraints_iter)
  {
    const Constraint2d &constraint = *constraints_iter;
    std::map<int, Pose2d>::iterator pose_begin_iter = poses->find(constraint.id_begin);
    std::map<int, Pose2d>::iterator pose_end_iter = poses->find(constraint.id_end);

    // Root information
    const Eigen::Matrix3d sqrt_information = constraint.information.llt().matrixL();

    // Ceres will take ownership of the pointer.
    ceres::CostFunction *cost_function =
        PoseGraph2dErrorTerm::Create(constraint.x, constraint.y, constraint.yaw_radians, sqrt_information);
    
    problem->AddResidualBlock(cost_function, nullptr,
                              &pose_begin_iter->second.x,
                              &pose_begin_iter->second.y,
                              &pose_begin_iter->second.yaw_radians,
                              &pose_end_iter->second.x,
                              &pose_end_iter->second.y,
                              &pose_end_iter->second.yaw_radians);
    problem->SetParameterization(&pose_begin_iter->second.yaw_radians, angle_local_parameterization);
    problem->SetParameterization(&pose_end_iter->second.yaw_radians, angle_local_parameterization);
  }
  std::map<int, Pose2d>::iterator pose_start_iter = poses->begin();
  problem->SetParameterBlockConstant(&pose_start_iter->second.x);
  problem->SetParameterBlockConstant(&pose_start_iter->second.y);
  problem->SetParameterBlockConstant(&pose_start_iter->second.yaw_radians);
}

2.4.2 angle update method

The angle update method is defined by overloading (), which limits that the angle is still between [- pi, pi] after updating. The code is very simple, so I won't say more

Then, through problem - > setparameterization (& pose_begin_iter - > second.yaw_radians, angle_local_parameterization); Associate the limitation of this angle update method with the angle variable

// Defines a local parameterization for updating the angle to be constrained in
// [-pi to pi).
class AngleLocalParameterization
{
public:
  template <typename T>
  bool operator()(const T *theta_radians, const T *delta_theta_radians, T *theta_radians_plus_delta) const
  {
    *theta_radians_plus_delta = NormalizeAngle(*theta_radians + *delta_theta_radians);

    return true;
  }

  static ceres::LocalParameterization *Create()
  {
    return (new ceres::AutoDiffLocalParameterization<AngleLocalParameterization, 1, 1>);
  }
};

// Normalizes the angle in radians between [-pi and pi).
template <typename T>
inline T NormalizeAngle(const T &angle_radians)
{
  // Use ceres::floor because it is specialized for double and Jet types.
  T two_pi(2.0 * M_PI);
  return angle_radians - two_pi * ceres::floor((angle_radians + T(M_PI)) / two_pi);
}

2.4.3 calculation of residuals

First, declare ceres::CostFunction through PoseGraph2dErrorTerm::Create() function, and pass in the constrained x, y, theta and the information matrix after square root to the constructor of PoseGraph2dErrorTerm class to save the data

Then, the residual calculation method is defined by overloading (). The parameters of the imitation function are passed in through problem - > addresidualblock (), which are xy and angle of the two poses respectively

The residuals between two postures can be calculated as follows
During the construction, the coordinate transformation between the two pose is introduced, and a coordinate transformation can be calculated through the pose of the two nodes. Under normal circumstances, the two coordinate transformations should be equal. Therefore, the difference between the two coordinate transformations can be used as the residual

Rotationmatrix2d (* yaw_a). Transfer() * (p_b - p_a) in the code is the coordinate transformation calculated according to the pose of the two nodes, P_ ab_. Cast < T > () is the coordinate transformation between the two postures, and the residual of xy is obtained by subtracting them

Similarly, the angle is subtracted to obtain the angle residual

Here, after the calculation is completed, the residual is left multiplied by the information matrix after square extraction, and the noise is added to the calculated residual through the covariance matrix

template <typename T>
Eigen::Matrix<T, 2, 2> RotationMatrix2D(T yaw_radians)
{
  const T cos_yaw = ceres::cos(yaw_radians);
  const T sin_yaw = ceres::sin(yaw_radians);

  Eigen::Matrix<T, 2, 2> rotation;
  rotation << cos_yaw, -sin_yaw, sin_yaw, cos_yaw;
  return rotation;
}

// Computes the error term for two poses that have a relative pose measurement
// between them. Let the hat variables be the measurement.
//
// residual =  information^{1/2} * [  r_a^T * (p_b - p_a) - \hat{p_ab}   ]
//                                 [ Normalize(yaw_b - yaw_a - \hat{yaw_ab}) ]
//
// where r_a is the rotation matrix that rotates a vector represented in frame A
// into the global frame, and Normalize(*) ensures the angles are in the range
// [-pi, pi).
class PoseGraph2dErrorTerm
{
public:
  PoseGraph2dErrorTerm(double x_ab, double y_ab, double yaw_ab_radians, const Eigen::Matrix3d &sqrt_information)
      : p_ab_(x_ab, y_ab), yaw_ab_radians_(yaw_ab_radians), sqrt_information_(sqrt_information)
  {
  }

  template <typename T>
  bool operator()(const T *const x_a, const T *const y_a, const T *const yaw_a,
                  const T *const x_b, const T *const y_b, const T *const yaw_b,
                  T *residuals_ptr) const
  {
    const Eigen::Matrix<T, 2, 1> p_a(*x_a, *y_a);
    const Eigen::Matrix<T, 2, 1> p_b(*x_b, *y_b);

    Eigen::Map<Eigen::Matrix<T, 3, 1>> residuals_map(residuals_ptr);

    residuals_map.template head<2>() = RotationMatrix2D(*yaw_a).transpose() * (p_b - p_a) - p_ab_.cast<T>();
    residuals_map(2) = NormalizeAngle((*yaw_b - *yaw_a) - static_cast<T>(yaw_ab_radians_));

    // Scale the residuals by the square root information matrix to account for
    // the measurement uncertainty.
    residuals_map = sqrt_information_.template cast<T>() * residuals_map;

    return true;
  }

  static ceres::CostFunction *Create(double x_ab, double y_ab, double yaw_ab_radians,
                                     const Eigen::Matrix3d &sqrt_information)
  {
    return (new ceres::AutoDiffCostFunction<PoseGraph2dErrorTerm, 3, 1, 1, 1, 1, 1, 1>(
        new PoseGraph2dErrorTerm(x_ab, y_ab, yaw_ab_radians, sqrt_information)));
  }

  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
private:
  // The position of B relative to A in the A frame.
  const Eigen::Vector2d p_ab_;
  // The orientation of frame B relative to frame A.
  const double yaw_ab_radians_;
  // The inverse square root of the measurement covariance matrix.
  const Eigen::Matrix3d sqrt_information_;
};

2.5 solution

This piece of code is relatively simple

First, the parameters of the solution are set up, then the ceres::Solve() is used to solve the problem, and the brief report of the solution is printed.

// Returns true if the solve was successful.
bool CeresSolver::SolveOptimizationProblem(ceres::Problem *problem)
{
  assert(problem != NULL);

  ceres::Solver::Options options;
  options.max_num_iterations = 100;
  options.linear_solver_type = ceres::SPARSE_NORMAL_CHOLESKY;

  ceres::Solver::Summary summary;
  ceres::Solve(options, problem, &summary);

  std::cout << summary.BriefReport() << '\n';
  return summary.IsSolutionUsable();
}

3 operation

3.1 dependency

The code of this article depends on the 1.13.0 version of ceres library. If ceres is not installed, you need to install it first
I put the installation package of ceres Library in the project Creating-2D-laser-slam-from-scratch/TrirdParty file, which can be extracted and installed directly

See install for the installation method_ The installation instructions in the dependency.sh script can also be directly executed to install all dependencies

After installing ceres, compile the code. If everything goes well, it can be compiled

Here, many students say that the code is compiled on ubuntu 18 or other ubuntu versions, but I can't help it. I've always used 1604 and haven't tried other versions. If it's really compiled, just look at the article or the source code

3.2 operation

The corresponding data package is returned to lesson6 in my official account, and the bag_ in launch is obtained. Change the filename to your actual directory name.

I put the links of the data packets I used before in the Tencent document. The address of the Tencent document is as follows:
https://docs.qq.com/sheet/DVElRQVNlY0tHU01I?tab=BB08J2

Run the program corresponding to this article with the following command
roslaunch lesson6 karto_slam_outdoor.launch solver_type:=ceres_solver

3.3 result analysis

After startup, the specific type of optimizer used will be displayed

[ INFO] [1637029966.740001825]: ----> Karto SLAM started.
[ INFO] [1637029966.790614727]: Use back end.
[ INFO] [1637029966.790716666]: solver type is CeresSolver.

In the early stage of operation, the loop has not been optimized because no loop has been found. When the loop is generated in the final stage, ceres based optimization will be called and the following log will be printed

[ INFO] [1637030162.060933669, 1606808844.440984444]: [ceres] Calling ceres for Optimization
Ceres Solver Report: Iterations: 8, Initial cost: 4.364248e-02, Final cost: 7.465524e-15, Termination: CONVERGENCE
[ INFO] [1637030162.089423589, 1606808844.471175613]: [ceres] Optimization finished

It can be seen that the cost before optimization is in the order of e-02 and the cost after optimization is in the order of e-15

Before optimization

After optimization

Final map

Final cost

If you look at the final cost, you can see that the cost after the previous optimization is e-15, but the last cost is e-02, which is much larger. The reason is not clear

It is speculated that there is still room for optimization due to the sudden end of the data???

If you know something, you can comment and tell me. Thank you very much

[ INFO] [1637032488.825845405, 1606808848.018522836]: [ceres] Calling ceres for Optimization
Ceres Solver Report: Iterations: 8, Initial cost: 6.562447e-02, Final cost: 5.582333e-15, Termination: CONVERGENCE
[ INFO] [1637032488.850353017, 1606808848.038643242]: [ceres] Optimization finished

[ INFO] [1637032489.159521180, 1606808848.351362847]: [ceres] Calling ceres for Optimization
Ceres Solver Report: Iterations: 8, Initial cost: 2.219165e-02, Final cost: 6.116063e-15, Termination: CONVERGENCE
[ INFO] [1637032489.183078270, 1606808848.371497779]: [ceres] Optimization finished

[ INFO] [1637032489.493828053, 1606808848.683609037]: [ceres] Calling ceres for Optimization
Ceres Solver Report: Iterations: 9, Initial cost: 4.919947e+01, Final cost: 1.920770e-02, Termination: CONVERGENCE
[ INFO] [1637032489.519517167, 1606808848.703747883]: [ceres] Optimization finished

4 Summary

Through this article, we know how to use ceres to build and calculate back-end optimization problems, and experience the map effect and cost value before and after optimization

The next article will use gtsam for back - end optimization calculations

Posted by melrse on Tue, 16 Nov 2021 00:40:44 -0800