cartographer代码学习-概率栅格地图(栅格地图的更新)

2024-04-14 17:36

本文主要是介绍cartographer代码学习-概率栅格地图(栅格地图的更新),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在cartographer中,地图的更新是很重要的一部分内容,如何将一帧一帧的激光点云转换成子图,则是其中的核心部分。

栅格地图的更新流程

根据前面所学,我们知道在local_trajectory_builder_2d中,函数在对点云预处理后调用了扫描匹配模块:

  // Step: 7 对 returns点云 进行自适应体素滤波,返回的点云的数据类型是PointCloudconst sensor::PointCloud& filtered_gravity_aligned_point_cloud =sensor::AdaptiveVoxelFilter(gravity_aligned_range_data.returns,options_.adaptive_voxel_filter_options());if (filtered_gravity_aligned_point_cloud.empty()) {return nullptr;}// local map frame <- gravity-aligned frame// 扫描匹配, 进行点云与submap的匹配std::unique_ptr<transform::Rigid2d> pose_estimate_2d =ScanMatch(time, pose_prediction, filtered_gravity_aligned_point_cloud);

扫描匹配可以获取到一个局部最优解,即当前机器人最有可能所在的实际位姿,该位姿会被用于后面栅格地图的更新,也就是下面的InsertIntoSubmap:

// 将二维坐标旋转回之前的姿态const transform::Rigid3d pose_estimate =transform::Embed3D(*pose_estimate_2d) * gravity_alignment;// 校准位姿估计器extrapolator_->AddPose(time, pose_estimate);// Step: 8 将 原点位于local坐标系原点处的点云 变换成 原点位于匹配后的位姿处的点云sensor::RangeData range_data_in_local =TransformRangeData(gravity_aligned_range_data,transform::Embed3D(pose_estimate_2d->cast<float>()));// 将校正后的雷达数据写入submapstd::unique_ptr<InsertionResult> insertion_result = InsertIntoSubmap(time, range_data_in_local, filtered_gravity_aligned_point_cloud,pose_estimate, gravity_alignment.rotation());

而实际上InsertIntoSubmap这个函数它本身是调用了active_submaps类下的InsertRangeData:

std::unique_ptr<LocalTrajectoryBuilder2D::InsertionResult>
LocalTrajectoryBuilder2D::InsertIntoSubmap(const common::Time time, const sensor::RangeData& range_data_in_local,const sensor::PointCloud& filtered_gravity_aligned_point_cloud,const transform::Rigid3d& pose_estimate,const Eigen::Quaterniond& gravity_alignment) {// 如果移动距离过小, 或者时间过短, 不进行地图的更新if (motion_filter_.IsSimilar(time, pose_estimate)) {return nullptr;}// 将点云数据写入到submap中std::vector<std::shared_ptr<const Submap2D>> insertion_submaps =active_submaps_.InsertRangeData(range_data_in_local);// 生成InsertionResult格式的数据进行返回return absl::make_unique<InsertionResult>(InsertionResult{std::make_shared<const TrajectoryNode::Data>(TrajectoryNode::Data{time,gravity_alignment,filtered_gravity_aligned_point_cloud,  // 这里存的是体素滤波后的点云, 不是校准后的点云{},  // 'high_resolution_point_cloud' is only used in 3D.{},  // 'low_resolution_point_cloud' is only used in 3D.{},  // 'rotational_scan_matcher_histogram' is only used in 3D.pose_estimate}),std::move(insertion_submaps)});
}

而这个函数对于点云的处理又分为了两个部分:新增子图以及更新子图:

// 将点云数据写入到submap中
std::vector<std::shared_ptr<const Submap2D>> ActiveSubmaps2D::InsertRangeData(const sensor::RangeData& range_data) {// 如果第二个子图插入节点的数据等于num_range_data时,就新建个子图// 因为这时第一个子图应该已经处于完成状态了if (submaps_.empty() ||submaps_.back()->num_range_data() == options_.num_range_data()) {AddSubmap(range_data.origin.head<2>());}// 将一帧雷达数据同时写入两个子图中for (auto& submap : submaps_) {submap->InsertRangeData(range_data, range_data_inserter_.get());}// 第一个子图的节点数量等于2倍的num_range_data时,第二个子图节点数量应该等于num_range_dataif (submaps_.front()->num_range_data() == 2 * options_.num_range_data()) {submaps_.front()->Finish();}return submaps();
}

在当前子图容器为空时或者前一张子图的插入数量达到阈值时,会新开一张子图。否则会调用InsertRangeData对当前子图进行雷达数据的插入。而InsertRangeData中实际调用的是RangeDataInserterInterface类中的Insert函数进行的插入操作:

// 将雷达数据写到栅格地图中
void Submap2D::InsertRangeData(const sensor::RangeData& range_data,const RangeDataInserterInterface* range_data_inserter) {CHECK(grid_);CHECK(!insertion_finished());// 将雷达数据写到栅格地图中range_data_inserter->Insert(range_data, grid_.get());// 插入到地图中的雷达数据的个数加1set_num_range_data(num_range_data() + 1);
}

注意到Insert函数中的grid_.get()是一个指针,这个指针是ActiveSubmaps2D在构造的时候根据传入的参数CreateRangeDataInserter进行的构造。

// ActiveSubmaps2D构造函数
ActiveSubmaps2D::ActiveSubmaps2D(const proto::SubmapsOptions2D& options): options_(options), range_data_inserter_(CreateRangeDataInserter()) {}// 返回指向 Submap2D 的 shared_ptr指针 的vector
std::vector<std::shared_ptr<const Submap2D>> ActiveSubmaps2D::submaps() const {return std::vector<std::shared_ptr<const Submap2D>>(submaps_.begin(),submaps_.end());
}

而这个CreateRangeDataInserter函数本身是创建了一个地图数据写入器,根据初始化参数决定使用的是概率栅格地图写入器还是tsdf地图的写入器。

// 创建地图数据写入器
std::unique_ptr<RangeDataInserterInterface>
ActiveSubmaps2D::CreateRangeDataInserter() {switch (options_.range_data_inserter_options().range_data_inserter_type()) {// 概率栅格地图的写入器case proto::RangeDataInserterOptions::PROBABILITY_GRID_INSERTER_2D:return absl::make_unique<ProbabilityGridRangeDataInserter2D>(options_.range_data_inserter_options().probability_grid_range_data_inserter_options_2d());// tsdf地图的写入器case proto::RangeDataInserterOptions::TSDF_INSERTER_2D:return absl::make_unique<TSDFRangeDataInserter2D>(options_.range_data_inserter_options().tsdf_range_data_inserter_options_2d());default:LOG(FATAL) << "Unknown RangeDataInserterType.";}
}

对于我们这边来说,CreateRangeDataInserter是建立了ProbabilityGridRangeDataInserter2D类的一个指针。

ProbabilityGridRangeDataInserter2D

简单看一下ProbabilityGridRangeDataInserter2D这个类,它包含了三个成员变量:

  const proto::ProbabilityGridRangeDataInserterOptions2D options_;const std::vector<uint16> hit_table_;const std::vector<uint16> miss_table_;

options_是传入的配置参数,hit_table_是指按照占用概率0.55更新之后的值,miss_table_是按照空闲概率0.49更新之后的值。

在ProbabilityGridRangeDataInserter2D中初始化了hit_table_与miss_table_这两个参数:

// 写入器的构造, 新建了2个查找表
ProbabilityGridRangeDataInserter2D::ProbabilityGridRangeDataInserter2D(const proto::ProbabilityGridRangeDataInserterOptions2D& options): options_(options),// 生成更新占用栅格时的查找表 // param: hit_probabilityhit_table_(ComputeLookupTableToApplyCorrespondenceCostOdds(Odds(options.hit_probability()))),    // 0.55// 生成更新空闲栅格时的查找表 // param: miss_probabilitymiss_table_(ComputeLookupTableToApplyCorrespondenceCostOdds(Odds(options.miss_probability()))) {} // 0.49

这边主要调用了ComputeLookupTableToApplyCorrespondenceCostOdds函数,但是传入的参数是不一样的,前者传入的是options.hit_probability()(0.55),后者传入的是options.miss_probability()(0.49)。对于ComputeLookupTableToApplyCorrespondenceCostOdds函数的作用,是将栅格是未知状态与odds状态下, 将更新时的所有可能结果预先计算出来:


// 将栅格是未知状态与odds状态下, 将更新时的所有可能结果预先计算出来
std::vector<uint16> ComputeLookupTableToApplyCorrespondenceCostOdds(float odds) {//预先申请一个32768的空间std::vector<uint16> result;result.reserve(kValueCount); // 32768// 当前cell是unknown情况下直接把odds转成value存进来result.push_back(CorrespondenceCostToValue(ProbabilityToCorrespondenceCost(ProbabilityFromOdds(odds))) +kUpdateMarker); // 加上kUpdateMarker作为一个标志, 代表这个栅格已经被更新了// 计算更新时 从1到32768的所有可能的 更新后的结果 for (int cell = 1; cell != kValueCount; ++cell) {result.push_back(CorrespondenceCostToValue(ProbabilityToCorrespondenceCost(ProbabilityFromOdds(odds * Odds(CorrespondenceCostToProbability((*kValueToCorrespondenceCost)[cell]))))) +kUpdateMarker);}return result;
}

ProbabilityFromOdds(odds)是将odds的值转换成概率:

inline float ProbabilityFromOdds(const float odds) {return odds / (odds + 1.f);
}

这个概率代表的占用的概率,而ProbabilityToCorrespondenceCost函数则是将其转换成空闲的概率:

inline float ProbabilityToCorrespondenceCost(const float probability) {return 1.f - probability;
}

再上层则是CorrespondenceCostToValue函数,其中调用的是BoundedFloatToValue函数,作用是将浮点的概率值转换成0-32767的整数计算。

此外上面还有一个kUpdateMarker参数,该参数是作为一个标志, 代表这个栅格已经被更新了。kUpdateMarker本身是一个32768这么一个值,这样就可以通过一个数据来判断这个栅格是否被更新了。为什么要增加这个标志,主要是为了防止同一个栅格在一次更新中被多次更新,这样子本栅格如果已经被更新了,那么本次数据的后续点云将不会再更新该栅格。

再看一下后面的:

  // 计算更新时 从1到32768的所有可能的 更新后的结果 for (int cell = 1; cell != kValueCount; ++cell) {result.push_back(CorrespondenceCostToValue(ProbabilityToCorrespondenceCost(ProbabilityFromOdds(odds * Odds(CorrespondenceCostToProbability((*kValueToCorrespondenceCost)[cell]))))) +kUpdateMarker);}

这部分,kValueToCorrespondenceCost指代的是映射表:

// [0, 1~32767] 映射成 [0.9, 0.1~0.9]转换表
const std::vector<float>* const kValueToCorrespondenceCost =PrecomputeValueToCorrespondenceCost().release();

从这里得到的是一个空闲的概率,然后通过CorrespondenceCostToProbability转换成占用的概率,然后再转换成odds的值进行乘法操作,得到栅格新的概率值。然后再通过ProbabilityFromOdds函数将其从odds转换成概率值,ProbabilityToCorrespondenceCost会将占用概率转成空闲概率,CorrespondenceCostToValue则是将空闲概率转换成Value。通过这样子一系列的操作,就可以根据传入的odds将栅格的概率进行更新。注意这里同样添加了kUpdateMarker标记。
这个标记添加后是在哪里进行删除的呢?在Grid_2d中对雷达结束后的数据进行了恢复:

// Finishes the update sequence.
// 插入雷达数据结束
void Grid2D::FinishUpdate() {while (!update_indices_.empty()) {DCHECK_GE(correspondence_cost_cells_[update_indices_.back()],kUpdateMarker);// 更新的时候加上了kUpdateMarker, 在这里减去correspondence_cost_cells_[update_indices_.back()] -= kUpdateMarker;update_indices_.pop_back();}
}

可以看到这里在结束的时候对每个栅格去除了kUpdateMarker。

然后再看一下ProbabilityGridRangeDataInserter2D中的另外一个函数Insert,这个函数就是第一部分中InsertRangeData所调用的函数实现了:

/*** @brief 将点云写入栅格地图* * @param[in] range_data 要写入地图的点云* @param[in] grid 栅格地图*/
void ProbabilityGridRangeDataInserter2D::Insert(const sensor::RangeData& range_data, GridInterface* const grid) const {ProbabilityGrid* const probability_grid = static_cast<ProbabilityGrid*>(grid);CHECK(probability_grid != nullptr);// By not finishing the update after hits are inserted, we give hits priority// (i.e. no hits will be ignored because of a miss in the same cell).// param: insert_free_spaceCastRays(range_data, hit_table_, miss_table_, options_.insert_free_space(),probability_grid);probability_grid->FinishUpdate();
}

可以看到这个函数主要是调用了CastRays函数,这个函数实现了将点云写入栅格地图的具体操作。展开看一下这个函数的具体实现:
第一步其调用了一个GrowAsNeeded函数,该函数的作用主要是对于地图的扩展。在cartographer中,子图的大小并不是固定的,会随着运动逐渐增大,其增大的处理方式就是按照这里的代码实现。
第二步对地图进行了分辨率的放大:

const MapLimits& limits = probability_grid->limits();const double superscaled_resolution = limits.resolution() / kSubpixelScale;const MapLimits superscaled_limits(superscaled_resolution, limits.max(),CellLimits(limits.cell_limits().num_x_cells * kSubpixelScale,limits.cell_limits().num_y_cells * kSubpixelScale));

这边的操作相当于将原有的分辨率放大了1000倍,获取了一个更加精细的高精度地图,这样可以使点云映射时画线画的更加细致。
第三步是将机器人姿态作为画线的原点放入到地图中:

// 雷达原点在地图中的像素坐标, 作为画线的起始坐标const Eigen::Array2i begin =superscaled_limits.GetCellIndex(range_data.origin.head<2>());

第四步是建立一个雷达终点所在栅格的容器,并更新终点所在地图栅格的占用值:

// Compute and add the end points.std::vector<Eigen::Array2i> ends;ends.reserve(range_data.returns.size());for (const sensor::RangefinderPoint& hit : range_data.returns) {// 计算hit点在地图中的像素坐标, 作为画线的终止点坐标ends.push_back(superscaled_limits.GetCellIndex(hit.position.head<2>()));// 更新hit点的栅格值probability_grid->ApplyLookupTable(ends.back() / kSubpixelScale, hit_table);}

更新是通过上述代码中的ApplyLookupTable函数实现的,这个函数函数中的更新主要是通过查找表的方式进行的更新,不需要再次进行计算,具体的查找表更新方式后续再单独整理。但是它的原理是跟原论文中的更新方式是一样的,只是实现方式的不同而以。

第五步是根据起点与点云的终点进行连线,并对连线上的栅格进行更新:

// Now add the misses.for (const Eigen::Array2i& end : ends) {std::vector<Eigen::Array2i> ray =RayToPixelMask(begin, end, kSubpixelScale);for (const Eigen::Array2i& cell_index : ray) {// 从起点到end点之前, 更新miss点的栅格值probability_grid->ApplyLookupTable(cell_index, miss_table);}}

RayToPixelMask函数是用于获取所有起点到终点所经过的所有栅格。然后再调用ApplyLookupTable函数,传入miss_table查找表进行栅格的更新。
第六步是对所有点云中超过范围的点的连线栅格的处理,在cartographer中,对于超出范围的点云,会用一个固定的值去替代(例如5米)。对于这些点云不会进行占用值的更新,但是会对连线上的所有点进行空闲值的更新:

  // Finally, compute and add empty rays based on misses in the range data.for (const sensor::RangefinderPoint& missing_echo : range_data.misses) {std::vector<Eigen::Array2i> ray = RayToPixelMask(begin, superscaled_limits.GetCellIndex(missing_echo.position.head<2>()),kSubpixelScale);for (const Eigen::Array2i& cell_index : ray) {// 从起点到misses点之前, 更新miss点的栅格值probability_grid->ApplyLookupTable(cell_index, miss_table);}}

这篇关于cartographer代码学习-概率栅格地图(栅格地图的更新)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/903586

相关文章

python实现pdf转word和excel的示例代码

《python实现pdf转word和excel的示例代码》本文主要介绍了python实现pdf转word和excel的示例代码,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价... 目录一、引言二、python编程1,PDF转Word2,PDF转Excel三、前端页面效果展示总结一

在MyBatis的XML映射文件中<trim>元素所有场景下的完整使用示例代码

《在MyBatis的XML映射文件中<trim>元素所有场景下的完整使用示例代码》在MyBatis的XML映射文件中,trim元素用于动态添加SQL语句的一部分,处理前缀、后缀及多余的逗号或连接符,示... 在MyBATis的XML映射文件中,<trim>元素用于动态地添加SQL语句的一部分,例如SET或W

使用C#代码计算数学表达式实例

《使用C#代码计算数学表达式实例》这段文字主要讲述了如何使用C#语言来计算数学表达式,该程序通过使用Dictionary保存变量,定义了运算符优先级,并实现了EvaluateExpression方法来... 目录C#代码计算数学表达式该方法很长,因此我将分段描述下面的代码片段显示了下一步以下代码显示该方法如

Redis缓存问题与缓存更新机制详解

《Redis缓存问题与缓存更新机制详解》本文主要介绍了缓存问题及其解决方案,包括缓存穿透、缓存击穿、缓存雪崩等问题的成因以及相应的预防和解决方法,同时,还详细探讨了缓存更新机制,包括不同情况下的缓存更... 目录一、缓存问题1.1 缓存穿透1.1.1 问题来源1.1.2 解决方案1.2 缓存击穿1.2.1

Linux Mint Xia 22.1重磅发布: 重要更新一览

《LinuxMintXia22.1重磅发布:重要更新一览》Beta版LinuxMint“Xia”22.1发布,新版本基于Ubuntu24.04,内核版本为Linux6.8,这... linux Mint 22.1「Xia」正式发布啦!这次更新带来了诸多优化和改进,进一步巩固了 Mint 在 Linux 桌面

python多进程实现数据共享的示例代码

《python多进程实现数据共享的示例代码》本文介绍了Python中多进程实现数据共享的方法,包括使用multiprocessing模块和manager模块这两种方法,具有一定的参考价值,感兴趣的可以... 目录背景进程、进程创建进程间通信 进程间共享数据共享list实践背景 安卓ui自动化框架,使用的是

SpringBoot生成和操作PDF的代码详解

《SpringBoot生成和操作PDF的代码详解》本文主要介绍了在SpringBoot项目下,通过代码和操作步骤,详细的介绍了如何操作PDF,希望可以帮助到准备通过JAVA操作PDF的你,项目框架用的... 目录本文简介PDF文件简介代码实现PDF操作基于PDF模板生成,并下载完全基于代码生成,并保存合并P

SpringCloud配置动态更新原理解析

《SpringCloud配置动态更新原理解析》在微服务架构的浩瀚星海中,服务配置的动态更新如同魔法一般,能够让应用在不重启的情况下,实时响应配置的变更,SpringCloud作为微服务架构中的佼佼者,... 目录一、SpringBoot、Cloud配置的读取二、SpringCloud配置动态刷新三、更新@R

SpringBoot基于MyBatis-Plus实现Lambda Query查询的示例代码

《SpringBoot基于MyBatis-Plus实现LambdaQuery查询的示例代码》MyBatis-Plus是MyBatis的增强工具,简化了数据库操作,并提高了开发效率,它提供了多种查询方... 目录引言基础环境配置依赖配置(Maven)application.yml 配置表结构设计demo_st

SpringCloud集成AlloyDB的示例代码

《SpringCloud集成AlloyDB的示例代码》AlloyDB是GoogleCloud提供的一种高度可扩展、强性能的关系型数据库服务,它兼容PostgreSQL,并提供了更快的查询性能... 目录1.AlloyDBjavascript是什么?AlloyDB 的工作原理2.搭建测试环境3.代码工程1.