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

相关文章

Redis中6种缓存更新策略详解

《Redis中6种缓存更新策略详解》Redis作为一款高性能的内存数据库,已经成为缓存层的首选解决方案,然而,使用缓存时最大的挑战在于保证缓存数据与底层数据源的一致性,本文将介绍Redis中6种缓存更... 目录引言策略一:Cache-Aside(旁路缓存)策略工作原理代码示例优缺点分析适用场景策略二:Re

SpringBoot中四种AOP实战应用场景及代码实现

《SpringBoot中四种AOP实战应用场景及代码实现》面向切面编程(AOP)是Spring框架的核心功能之一,它通过预编译和运行期动态代理实现程序功能的统一维护,在SpringBoot应用中,AO... 目录引言场景一:日志记录与性能监控业务需求实现方案使用示例扩展:MDC实现请求跟踪场景二:权限控制与

Pandas利用主表更新子表指定列小技巧

《Pandas利用主表更新子表指定列小技巧》本文主要介绍了Pandas利用主表更新子表指定列小技巧,通过创建主表和子表的DataFrame对象,并使用映射字典进行数据关联和更新,实现了从主表到子表的同... 目录一、前言二、基本案例1. 创建主表数据2. 创建映射字典3. 创建子表数据4. 更新子表的 zb

使用Python和Pyecharts创建交互式地图

《使用Python和Pyecharts创建交互式地图》在数据可视化领域,创建交互式地图是一种强大的方式,可以使受众能够以引人入胜且信息丰富的方式探索地理数据,下面我们看看如何使用Python和Pyec... 目录简介Pyecharts 简介创建上海地图代码说明运行结果总结简介在数据可视化领域,创建交互式地

Java学习手册之Filter和Listener使用方法

《Java学习手册之Filter和Listener使用方法》:本文主要介绍Java学习手册之Filter和Listener使用方法的相关资料,Filter是一种拦截器,可以在请求到达Servl... 目录一、Filter(过滤器)1. Filter 的工作原理2. Filter 的配置与使用二、Listen

利用Python调试串口的示例代码

《利用Python调试串口的示例代码》在嵌入式开发、物联网设备调试过程中,串口通信是最基础的调试手段本文将带你用Python+ttkbootstrap打造一款高颜值、多功能的串口调试助手,需要的可以了... 目录概述:为什么需要专业的串口调试工具项目架构设计1.1 技术栈选型1.2 关键类说明1.3 线程模

Python Transformers库(NLP处理库)案例代码讲解

《PythonTransformers库(NLP处理库)案例代码讲解》本文介绍transformers库的全面讲解,包含基础知识、高级用法、案例代码及学习路径,内容经过组织,适合不同阶段的学习者,对... 目录一、基础知识1. Transformers 库简介2. 安装与环境配置3. 快速上手示例二、核心模

Java的栈与队列实现代码解析

《Java的栈与队列实现代码解析》栈是常见的线性数据结构,栈的特点是以先进后出的形式,后进先出,先进后出,分为栈底和栈顶,栈应用于内存的分配,表达式求值,存储临时的数据和方法的调用等,本文给大家介绍J... 目录栈的概念(Stack)栈的实现代码队列(Queue)模拟实现队列(双链表实现)循环队列(循环数组

使用Java将DOCX文档解析为Markdown文档的代码实现

《使用Java将DOCX文档解析为Markdown文档的代码实现》在现代文档处理中,Markdown(MD)因其简洁的语法和良好的可读性,逐渐成为开发者、技术写作者和内容创作者的首选格式,然而,许多文... 目录引言1. 工具和库介绍2. 安装依赖库3. 使用Apache POI解析DOCX文档4. 将解析

C++使用printf语句实现进制转换的示例代码

《C++使用printf语句实现进制转换的示例代码》在C语言中,printf函数可以直接实现部分进制转换功能,通过格式说明符(formatspecifier)快速输出不同进制的数值,下面给大家分享C+... 目录一、printf 原生支持的进制转换1. 十进制、八进制、十六进制转换2. 显示进制前缀3. 指