讲解关于slam一系列文章汇总链接:史上最全slam从零开始,针对于本栏目讲解(02)Cartographer源码无死角解析-链接如下:
(02)Cartographer源码无死角解析- (00)目录_最新无死角讲解:https://blog.csdn.net/weixin_43013761/article/details/127350885
文末正下方中心提供了本人
联系方式,
点击本人照片即可显示
W
X
→
官方认证
{\color{blue}{文末正下方中心}提供了本人 \color{red} 联系方式,\color{blue}点击本人照片即可显示WX→官方认证}
文末正下方中心提供了本人联系方式,点击本人照片即可显示WX→官方认证
一、前言
上一篇博客介绍的 landmark 数据的残差与优化,最后本人分享了如下:
核心理解 : \color{red}核心理解: 核心理解: 在上一篇博客中介绍的约束残差(节点相对与子图位姿的残差),其主是优化每个节点以及每个子图的位姿。可想而知,其是离散的。但是轨迹是一条连续平滑的曲线,所以本质上我们希望通过插值获取到的位姿也是准确的。恰好, landmark帧的时间点,也激光雷达数据帧的时间点是不一样的,所以如果 landmark帧的时间点位于两个激光雷达数据帧的时间点,那么可以把landmark帧相对于机器人的位姿作为一个约束,对两节点插值之后的位姿进行优化,从而广播到两个节点,间接对两个节点进行优化。
现在回到 OptimizationProblem2D::Solve() 函数,到目前为止,已经把:
// Add cost functions for landmarks.
// Step: landmark数据 与 通过2个节点位姿插值出来的相对位姿 的差值作为残差项
AddLandmarkCostFunctions(landmark_nodes, node_data_, &C_nodes, &C_landmarks,
&problem, options_.huber_scale());
讲解完成了,不过看起来,任务量还是很多的,似乎还剩下很多代码都没有讲解。起始不然,因为后面的代码比较简单,另外再前面已经接触过类似的代码,分析起来就没有那么困难。
二、残差准备
接着代码 OptimizationProblem2D::Solve() 函数调用的 AddLandmarkCostFunctions() 之后继续分析。首先其又运行了一个for循环,且其中也嵌套一个层for循环,先来看第一层循环,代码如下:
// 遍历多个轨迹, 添加里程计与local结果的残差
for (auto node_it = node_data_.begin(); node_it != node_data_.end();) {
// 获取每个节点的轨迹id
const int trajectory_id = node_it->id.trajectory_id;
// 获取这条轨迹的最后一个位置的迭代器
const auto trajectory_end = node_data_.EndOfTrajectory(trajectory_id);
// 如果轨迹是frozen的, 则无需处理直接跳过
if (frozen_trajectories.count(trajectory_id) != 0) {
node_it = trajectory_end;
continue;
}
auto prev_node_it = node_it;
需要注意,这里的for循环时对轨迹遍历,并不是对节点的遍历,如果 node_data_ 中包含了多条轨迹的节点,node_data_.begin() 表示 trajectory_id==0 的第一个节点,而 node_data_.end() 表示 trajectory_id 等于最大轨迹的第一个节点。先获得轨迹起始节点 node_it 对应轨迹标识 trajectory_id。然后获得这条轨迹结束位姿的迭代器,记录在 trajectory_end,接着判断一下目前节点对应的轨迹是否为冻结状态,如果为冻结状态则 continue 跳出第一层循环。然后把当前 node_it 记录给 prev_node_it,也就是 prev_node_it 记录的目前轨迹的起始节点。接着代码分析,可以看到其嵌套了一个循环:
// 遍历一个轨迹的所有节点, 添加里程计与local结果的残差
for (++node_it; node_it != trajectory_end; ++node_it) {
const NodeId first_node_id = prev_node_it->id;
const NodeSpec2D& first_node_data = prev_node_it->data;
prev_node_it = node_it;
const NodeId second_node_id = node_it->id;
const NodeSpec2D& second_node_data = node_it->data;
// 如果节点的索引不连续, 跳过
if (second_node_id.node_index != first_node_id.node_index + 1) {
continue;
}
该循环就是对一条轨迹的节点进行遍历,prev_node_it 主要用于记录上一节点,然后判断当前节点与上一节点索引是否连续,如果不连续,则会 continue 跳出循环,该节点不参与下面的残差计算。
另外,其还获取了当前节点与上一节点的节点数据,分别存储于 first_node_data 与 second_node_data。
三、里程计插值
首先根据两个连续的节点数据 first_node_data 与 second_node_data 的时间,然后对里程计数据进行插值处理,获得里程计在这两个时间点的 local 系下的位姿,里程计数据的线性插值,被调代码如下:
// Add a relative pose constraint based on the odometry (if available).
// 根据里程计数据进行插值得到的2个节点间的坐标变换
std::unique_ptr<transform::Rigid3d> relative_odometry =
CalculateOdometryBetweenNodes(trajectory_id, first_node_data,
second_node_data);
函数 CalculateOdometryBetweenNodes() 实现于 optimization_problem_2d.cc 文件中,代码注释如下:
/**
* @brief 根据里程计数据算出两个节点间的相对坐标变换
*
* @param[in] trajectory_id 轨迹的id
* @param[in] first_node_data 前一个节点数据
* @param[in] second_node_data 后一个节点数据
* @return std::unique_ptr<transform::Rigid3d> 两个节点的坐标变换
*/
std::unique_ptr<transform::Rigid3d>
OptimizationProblem2D::CalculateOdometryBetweenNodes(
const int trajectory_id, const NodeSpec2D& first_node_data,
const NodeSpec2D& second_node_data) const {
if (odometry_data_.HasTrajectory(trajectory_id)) {
// 插值得到time时刻的里程计数据
const std::unique_ptr<transform::Rigid3d> first_node_odometry =
InterpolateOdometry(trajectory_id, first_node_data.time);
const std::unique_ptr<transform::Rigid3d> second_node_odometry =
InterpolateOdometry(trajectory_id, second_node_data.time);
if (first_node_odometry != nullptr && second_node_odometry != nullptr) {
// 根据里程计数据算出的相对坐标变换
// 需要注意的是, 实际上在optimization_problem中, node的位姿都是2d平面上的
// 而odometry的pose是带姿态的, 因此要将轮速计插值出来的位姿转到平面上
transform::Rigid3d relative_odometry =
transform::Rigid3d::Rotation(first_node_data.gravity_alignment) *
first_node_odometry->inverse() * (*second_node_odometry) *
transform::Rigid3d::Rotation(
second_node_data.gravity_alignment.inverse());
return absl::make_unique<transform::Rigid3d>(relative_odometry);
}
}
return nullptr;
}
其代码也比较简单,就是先判断一下 odometry_data_ 中有没有 trajectory_id 对应的里程计数据,如果有就根据 first_node_data 与 second_node_data 获得需要插值求得位姿的时刻,然后进行插值。这里的插值函数 InterpolateOdometry() 其权重依旧由 time 与前后两个里程计节点的时间距离来决定的。求得 first_node_data.time 与 second_node_data.time 对应插值里程计位姿之后的 local 位姿之后,再进一步获得两节点基于里程计的相对位姿变换。
四、里程计残差
// Step: 第三种残差 节点与节点间在global坐标系下的相对坐标变换 与 通过里程计数据插值出的相对坐标变换 的差值作为残差项
// 如果存在里程计则可增加一个残差
if (relative_odometry != nullptr) {
problem.AddResidualBlock(
CreateAutoDiffSpaCostFunction(Constraint::Pose{
*relative_odometry, options_.odometry_translation_weight(),
options_.odometry_rotation_weight()}),
nullptr /* loss function */,
C_nodes.at(first_node_id).data(),
C_nodes.at(second_node_id).data());
}
如果存在里程计则可增加一个残差,该残差以 relative_odometry 作为帧值(根据里程计计算出来两节点的位姿变换),然后让根据两节点global系下的节点位姿计算出两节点的相对位姿,优化过程就是让后者向前者靠近。
四、local残差
这是一种比较好理解的残差,再前端求得节点位姿时,使用了扫描匹配,以及Ceres优化,尽量让一帧点云全部打在障碍物上,很明显其时比较优的解,这里我们认为其时正确的,对于相邻的两个节点,都是通过这种方式估算出来的 local 位姿,因为间隔时间短,他们之间的累计误差时可以忽略的,也就是说, local 系下两个节点的相对变换基本同理基本时正确的,可以把其作为一个约束来对待,让 global 系下两个节点之间的位姿变换通过Ceres优化,逼近于local系下对应两个节点的位姿变换。代码如下所示:
// Add a relative pose constraint based on consecutive local SLAM poses.
// 计算相邻2个节点在local坐标系下的坐标变换
const transform::Rigid3d relative_local_slam_pose =
transform::Embed3D(first_node_data.local_pose_2d.inverse() *
second_node_data.local_pose_2d);
// Step: 第四种残差 节点与节点间在global坐标系下的相对坐标变换 与 相邻2个节点在local坐标系下的相对坐标变换 的差值作为残差项
problem.AddResidualBlock(
CreateAutoDiffSpaCostFunction(
Constraint::Pose{relative_local_slam_pose,
options_.local_slam_pose_translation_weight(),
options_.local_slam_pose_rotation_weight()}),
nullptr /* loss function */,
C_nodes.at(first_node_id).data(),
C_nodes.at(second_node_id).data());
}
}
五、GPS残差-准备工作与约束计算
对于GPS残差部分,又是两个for循环进行嵌套的代码,且与前面十分相似如下,第一层for循环依旧是对轨迹的遍历。
// 遍历多个轨迹, 添加gps的残差
std::map<int, std::array<double, 3>> C_fixed_frames;
for (auto node_it = node_data_.begin(); node_it != node_data_.end();) {
const int trajectory_id = node_it->id.trajectory_id;
const auto trajectory_end = node_data_.EndOfTrajectory(trajectory_id);
if (!fixed_frame_pose_data_.HasTrajectory(trajectory_id)) {
node_it = trajectory_end;
continue;
}
const TrajectoryData& trajectory_data = trajectory_data_.at(trajectory_id);
bool fixed_frame_pose_initialized = false;
其先判断一下该轨迹是否存在GPU数据,没有没有,则 continue 遍历下一条轨迹。如果存在则获得该轨迹信息,轨迹信息主要包含了如下内容:
struct TrajectoryData {
double gravity_constant = 9.8;
std::array<double, 4> imu_calibration{{1., 0., 0., 0.}};
absl::optional<transform::Rigid3d> fixed_frame_origin_in_map;
};
先把 fixed_frame_pose_initialized 设置为 false,接着就是对轨迹的每个节点进行遍历了:
// 遍历一个轨迹的所有节点, 添加gps的残差
for (; node_it != trajectory_end; ++node_it) {
const NodeId node_id = node_it->id;
const NodeSpec2D& node_data = node_it->data;
// 根据节点的时间对gps数据进行插值, 获取这个时刻的gps数据的位姿
const std::unique_ptr<transform::Rigid3d> fixed_frame_pose =
Interpolate(fixed_frame_pose_data_, trajectory_id, node_data.time);
// 要找到第一个有效的数据才能进行残差的计算
if (fixed_frame_pose == nullptr) {
continue;
}
// gps数据到gps第一个数据间的坐标变换
const Constraint::Pose constraint_pose{
*fixed_frame_pose, options_.fixed_frame_pose_translation_weight(),
options_.fixed_frame_pose_rotation_weight()};
先获得遍历轨迹节的time,利用 FixedFramePoseData 数据进行插值,获得 GPS该时刻的位姿 fixed_frame_pose,需要注意 gps数据是到gps第一个数据间的坐标变换,那么可以为这个变换创建一个约束constraint_pose,其中 constraint_pose.zbar_ij 就是 *fixed_frame_pose。
五、GPS残差-global系到gps系变换
在建立好 约束 constraint_pose 之后,如果 fixed_frame_pose_initialized 不为true,也就说目前遍历的是该轨迹的第一个节点,那么则对GPS位姿进行一个初始化处理:
// 计算gps坐标系原点在global坐标系下的坐标
if (!fixed_frame_pose_initialized) {
transform::Rigid2d fixed_frame_pose_in_map;
// 如果设置了gps数据的原点
if (trajectory_data.fixed_frame_origin_in_map.has_value()) {
fixed_frame_pose_in_map = transform::Project2D(
trajectory_data.fixed_frame_origin_in_map.value());
}
// 第一次优化之前执行的是这里
else {
// 计算gps第一个数据在global坐标系下的坐标, 相当于gps数据的坐标系原点在global坐标系下的坐标
fixed_frame_pose_in_map =
node_data.global_pose_2d *
transform::Project2D(constraint_pose.zbar_ij).inverse();
}
// 保存gps坐标系原点在global坐标系下的坐标
C_fixed_frames.emplace(trajectory_id,
FromPose(fixed_frame_pose_in_map));
fixed_frame_pose_initialized = true;
}
其先判断一下该轨迹是否设置了GPS原点,也就是 trajectory_data.fixed_frame_origin_in_map 参数是否设置,如果设置了,则把其映射到2D平面赋值给 fixed_frame_pose_in_map。
如果没有设置,node_data.global_pose_2d 表示global下机器人位姿,ransform::Project2D(constraint_pose.zbar_ij) 表示机器人在GPS下的位姿,
fixed_frame_pose_in_map =
node_data.global_pose_2d *
transform::Project2D(constraint_pose.zbar_ij).inverse();
那么 fixed_frame_pose_in_map 就表示GPS系相对于 Global系的位姿了。也就是说
fixed_frame_pose_in_map 表示GPU第一个数据在global系下的位姿,计算出来之后,fixed_frame_pose_initialized 赋值为 true,就不会再为该轨迹计算 fixed_frame_pose_in_map 了。求得 fixed_frame_pose_in_map 会被添加到 C_fixed_frames 之中。
六、GPS残差-残差构建
最后就是最核心的残差构建部分了,主体代码如下所示:
// Step: 第五种残差 节点与gps坐标系原点在global坐标系下的相对坐标变换 与 通过gps数据进行插值得到的相对坐标变换 的差值作为残差项
problem.AddResidualBlock(
CreateAutoDiffSpaCostFunction(constraint_pose),
options_.fixed_frame_pose_use_tolerant_loss()
? new ceres::TolerantLoss(
options_.fixed_frame_pose_tolerant_loss_param_a(),
options_.fixed_frame_pose_tolerant_loss_param_b())
: nullptr,
C_fixed_frames.at(trajectory_id).data(), // 会自动调用AddParameterBlock
C_nodes.at(node_id).data());
}
constraint_pose 是利用GPS数据插值出来当前遍历节点 node_it 对应的位姿。ceres::TolerantLoss() 同样用于残差结果的抑制异常点。根据 C_fixed_frames.at(trajectory_id).data() 表示GPU系在global系下的位姿,
C_nodes.at(node_id).data()) 表示当前节点在global系下的位姿,那么可以求得当前节点相对于GPS系的位姿。再与约束 constraint_pose 做残差。
七、开始优化,且更新数据
// Solve. 进行求解
ceres::Solver::Summary summary;
ceres::Solve(
common::CreateCeresSolverOptions(options_.ceres_solver_options()),
&problem, &summary);
// 如果开启了优化的log输出, 就输出ceres的报告
if (options_.log_solver_summary()) {
LOG(INFO) << summary.FullReport();
}
// 将优化后的所有数据进行更新 Store the result.
for (const auto& C_submap_id_data : C_submaps) {
submap_data_.at(C_submap_id_data.id).global_pose =
ToPose(C_submap_id_data.data);
}
for (const auto& C_node_id_data : C_nodes) {
node_data_.at(C_node_id_data.id).global_pose_2d =
ToPose(C_node_id_data.data);
}
for (const auto& C_fixed_frame : C_fixed_frames) {
trajectory_data_.at(C_fixed_frame.first).fixed_frame_origin_in_map =
transform::Embed3D(ToPose(C_fixed_frame.second));
}
for (const auto& C_landmark : C_landmarks) {
landmark_data_[C_landmark.first] = C_landmark.second.ToRigid();
}
八、结语
到这里位姿,可以说后端优化或者位姿图的讲解基本就完成了,总的来说,后端优化中共有五种残差,分别如下所示:
1、基于节点与子图(子图内,子图间)约束的残差
2、基于Landmark的残差
3、基于odometry里程计的残差
4、基于节点local系下的残差
5、基于GPS数据的残差
下一篇博客,就带代价对后端优化进行一个整理的复盘,让大家的理解更加