(五) 传感器数据的分发处理

SensorBridge::HandleLaserScan之后进入下面的流程:

把雷达和imu、odom数据队列按时间整理,没有对齐

trajectory_builder_->AddSensorData当中的trajectory_builder_其实是CollatedTrajectoryBuilder,它的根源在sensor_bridge的初始化:

1
2
3
4
5
6
7
sensor_bridges_[trajectory_id] = 
cartographer::common::make_unique<SensorBridge>(
trajectory_options.num_subdivisions_per_laser_scan,
trajectory_options.tracking_frame,
node_options_.lookup_transform_timeout_sec, tf_buffer_,
// 实际类型是 CollatedTrajectoryBuilder
map_builder_->GetTrajectoryBuilder(trajectory_id) );

GetTrajectoryBuilder的里面是trajectory_builders_.push_back( absl::make_unique<CollatedTrajectoryBuilder>() ),已经写死了,所以说 后面的TrajectoryBuilderInterface只能是CollatedTrajectoryBuilder

trajectory_builder_->AddSensorData实际是:

1
2
3
4
5
6
void AddSensorData(
const std::string& sensor_id,
const sensor::TimedPointCloudData& timed_point_cloud_data) override
{
AddData(sensor::MakeDispatchable(sensor_id, timed_point_cloud_data));
}

将一个sensor::TimedPointCloudData的数据,变换成了 Dispatchable 的格式。 Dispatchable是一个类模板,所有类型的数据, 都会被放在data_里面,这个的作用会在后面显现,靠这个去判断调用的是GlobalTrajectoryBuilder里面的哪个函数

AddData就一句sensor_collator_->AddSensorData(trajectory_id_, std::move(data));. 根据参数collate_by_trajectoryCollatorTrajectoryCollator)选择, 默认是sensor::Collator,所以是sensor::Collator::AddSensorData:

1
2
3
4
5
6
7
8
void Collator::AddSensorData(const int trajectory_id,
std::unique_ptr<Data> data)
{
QueueKey queue_key{trajectory_id, data->GetSensorId()};
// Queue keys are a pair of trajectory ID and sensor identifier
// OrderedMultiQueue queue_;
queue_.Add(std::move(queue_key), std::move(data));
}

然后会调用OrderedMultiQueue::add()的函数,把数据存入队列里,形成有序的多重队列 queues_
1
2
3
(0, scan):  {      4,   }    带callback
(0, imu) : {1, 3, 5 } 带callback
(0, odom): { 2, 6} 带callback

最后再调用OrderedMultiQueue::Dispatch()

OrderedMultiQueue::Dispatch()

函数将队列中的数据根据时间依次传入回调函数 GlobalTrajectoryBuilder::AddSensorData

这里的处理是生产者——消费者模式。 生产者 OrderedMultiQueue::Add     消费者 CollatedTrajectoryBuilder::HandleCollatedSensorData

这个分发函数就太复杂了,涉及到OrderedMultiQueue这个数据结构了,它的主要作用就是管理多个有序的传感器数据, 主要的体现就是成员变量std::map<QueueKey, Queue> queues_,它会形成这么一个组织结构:

1
2
key1(sensor_1): queue
key2(sensor_2): queue

queue里面是按时间的数据的组织:
1
2
3
4
5
struct Queue {
common::BlockingQueue<std::unique_ptr<Data>> queue;
Callback callback; //回调
bool finished = false;
};

这里发现了Queue有个成员是 callback 函数,在Dispatch函数中,如果找出来的数据, 那么就调用这个数据的callback函数。那么在哪儿引入了这个callback函数呢? 结果发现是在OrderedMultiQueue::AddQueue,它又在Collator::AddTrajectory中调用,这样就有了另一条线。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// CollatedTrajectoryBuilder 的构造函数调用该函数
// CollatedTrajectoryBuilder 的 HandleCollatedSensorData 作为回调函数callback传进来
// 添加轨迹以生成排序的传感器输出,每个topic设置一个回调函数
void Collator::AddTrajectory(
const int trajectory_id,
const absl::flat_hash_set<std::string>& expected_sensor_ids,
const Callback& callback)
{
for (const auto& sensor_id : expected_sensor_ids)
{
const auto queue_key = QueueKey{trajectory_id, sensor_id};
// 根据QueueKey,将对应的回调函数callback(CollatedTrajectoryBuilder::HandleCollatedSensorData)
// 放入 queue_ (OrderedMultiQueue::AddQueue)
queue_.AddQueue(queue_key,
[callback, sensor_id](std::unique_ptr<Data> data) {
callback(sensor_id, std::move(data));
});
queue_keys_[trajectory_id].push_back(queue_key);
}
}

CollatedTrajectoryBuilder处理传感器数据,使其按照时间排列,然后传入GlobalTrajectoryBuilder,最终的回调函数其实就是GlobalTrajectoryBuilder::AddSensorData


(六) 核心 --- GlobalTrajectoryBuilder::AddSensorData

TrajectoryBuilder是用来创建一个trajectory的。

该类首先要保存trajectory上的传感器数据,从连续的传感器数据中会抽取若干关键帧,一帧的关键帧数据被称为trajectory上的一个节点,一条trajectory由一串节点组成,所以TrajectoryBuilder要维护一个节点列表

该类要创建并维护一个栅格化的Submap列表,以便在MapBuilder中对所有的submap进行优化,形成一个总的Map。每个Submap相对于世界坐标系的位姿要估计出来,这样PoseGraph才能依据不同的submap各自的位姿变换矩阵,把他们tie在一起并做全局优化。

GlobalTrajectoryBuilder

TrajectoryBuilderInterface的继承关系

分为前端和后端两部分:local_trajectory_builder和pose_graph。GlobalTrajectoryBuilder实际上是一个模板类,模板列表中的LocalTrajectoryBuilder2DPoseGraph2D分别是前端和后端的两个核心类型。

成员变量很少:

类体内的成员函数AddLocalSlamResultData是把前端结果直接给后端,但是不能是当前的前端。这个仅用于后端约束优化。这个函数没有被调用,LocalSlamResult2D::AddToTrajectoryBuilder中被调用的同名函数应当是CollatedTrajectoryBuilder

时间点云的 AddSensorData

类有多个AddSensorData函数,其中最重要的是时间点云对应的,也是cartographer最核心的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 参数sensor_id记录了产生数据的传感器, timed_point_cloud_data则是传感器产生的数据
void AddSensorData(
const std::string& sensor_id,
const sensor::TimedPointCloudData& timed_point_cloud_data) override
{
// 检查一下前端核心对象是否存在
CHECK(local_trajectory_builder_)
<< "Cannot add TimedPointCloudData without a LocalTrajectoryBuilder.";
// Local SLAM的业务主线, 在该结果中同时记录了子图的更新信息
std::unique_ptr<typename LocalTrajectoryBuilder::MatchingResult>
matching_result = local_trajectory_builder_->AddRangeData(
sensor_id, timed_point_cloud_data);
// 结果中包含了轨迹中节点的pose、点云和submap的相关信息
if (matching_result == nullptr)
// The range data 还没有fully accumulated yet.
return;


/* 将前端的输出结果喂给后端进行闭环检测和全局优化 */
kLocalSlamMatchingResults->Increment();
// 前端匹配的结果,即submap位置和submap
std::unique_ptr<InsertionResult> insertion_result;
// 判定前端成功的是否将传感器的数据插入到子图中
if (matching_result->insertion_result != nullptr)
{
kLocalSlamInsertionResults->Increment();
// 将匹配后的结果加入图优化节点中
// 把前端的输出结果喂给后端,即后端增加一个新的节点
// 后端优化位姿图节点ID,记录在临时对象 node_id
auto node_id = pose_graph_->AddNode(
matching_result->insertion_result->constant_data, trajectory_id_,
matching_result->insertion_result->insertion_submaps );
CHECK_EQ(node_id.trajectory_id, trajectory_id_);

// 重新封装前端匹配的后的submap结果,并增加一个node_id
insertion_result = absl::make_unique<InsertionResult>( InsertionResult{
node_id, matching_result->insertion_result->constant_data,
std::vector<std::shared_ptr<const Submap>>(
matching_result->insertion_result->insertion_submaps.begin(),
matching_result->insertion_result->insertion_submaps.end()) } );
}
// 如果提供了回调函数,就调用之,并将前端的输出和刚刚构建的
//insertion_result对象传参,也就是采用回调函数将前端匹配的结果信息传递出去
if (local_slam_result_callback_)
{
local_slam_result_callback_(
trajectory_id_, matching_result->time, matching_result->local_pose,
std::move(matching_result->range_data_in_local),
std::move(insertion_result) );
}
}

基类TrajectoryBuilderInterface中有个成员变量InsertionResult,它描述前端的一次子图更新操作,将传感器的扫描数据插入子图中。
1
2
3
4
5
6
7
8
9
struct InsertionResult
{
// 有两个字段,分别记录了轨迹的索引(trajectory_id)和一个从零开始计数的节点编号(node_index)
NodeId node_id;
// 子图更新时在局部地图中的位姿,以及有传感器原始数据转换之后的点云信息
std::shared_ptr<const TrajectoryNode::Data> constant_data;
// 被更新的子图对象
std::vector<std::shared_ptr<const Submap2D>> insertion_submaps;
};

根据NodeId,可以把一条轨迹理解为由若干个节点串联起来的一个数据结构。

LocalTrajectoryBuilder2D中也有一个InsertionResult但是注意前端类不是继承自 TrajectoryBuilderInterface, 这两个没有直接关系,前端只关心更新子图时的位姿和点云信息,不考虑在它与整个运动轨迹之间的关系,所以缺少了字段node_id

1
2
3
4
5
6
struct InsertionResult
{
// 插入的节点数据,TrajectoryNode::Data 包含了处理之后的点云数据
std::shared_ptr<const TrajectoryNode::Data> constant_data;
std::vector<std::shared_ptr<const Submap2D>> insertion_submaps;
};

里程计和IMU的 AddSensorData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void AddSensorData(const std::string& sensor_id,
const sensor::ImuData& imu_data) override
{
// InitializeExtrapolator 和 extrapolator_->AddImuData
if (local_trajectory_builder_)
local_trajectory_builder_->AddImuData(imu_data);

pose_graph_->AddImuData(trajectory_id_, imu_data);
}

void AddSensorData(const std::string& sensor_id,
const sensor::OdometryData& odometry_data) override
{
CHECK(odometry_data.pose.IsValid()) << odometry_data.pose;
if (local_trajectory_builder_)
// 实际有用的就一句 extrapolator_->AddOdometryData(odometry_data);
local_trajectory_builder_->AddOdometryData(odometry_data);

pose_graph_->AddOdometryData(trajectory_id_, odometry_data);
}

先判定存在前端对象,将数据喂给前端对象进入PoseExtrapolator,然后通过pose_graph将传感器的信息添加到全局地图中。

Cartographer将类似GPS这种具有全局定位能力的传感器输出的位姿称为 固定坐标系位姿(fixed frame pose)。由于它们的测量结果是全局的信息,所以没有喂给前端用于局部定位。此外路标数据也可以认为是全局的定位信息,也直接喂给了后端


(四) LaunchSubscribers流程

上一篇的Node::LaunchSubscribers的编程技巧比较复杂,只需要知道是执行 Node::Handle***Message那几个函数即可

流程图
然后就进入了 GlobalTrajectoryBuilder::AddSensorData

1
2
3
4
5
6
7
8
9
10
11
void SensorBridge::HandleLaserScanMessage(
const std::string& sensor_id, const sensor_msgs::LaserScan::ConstPtr& msg)
{
// 转换之后的点云数据和时间戳
carto::sensor::PointCloudWithIntensities point_cloud;
carto::common::Time time;
// 将ROS的消息转换成点云数据,函数在 msg_conversion.cc
// 只有一句 LaserScanToPointCloudWithIntensities(msg)
std::tie(point_cloud, time) = ToPointCloudWithIntensities(*msg);
HandleLaserScan(sensor_id, time, msg->header.frame_id, point_cloud);
}

只要一个时间是 carto::common::Time 类型,那么一定是绝对时间戳。

TimedPointCloud是后面一直使用的类型,转换流程在这里
类型转换

LaserScanToPointCloudWithIntensities

不考虑强度时,用雷达扫描只获得range和angle两个参数,LaserScanToPointCloudWithIntensities(msg)实际是把这两个参数转换为扫描点在laser坐标系下的坐标,时间戳以最后一束激光为基准。 同时每个激光点也包含一个相对最后一个点的时间偏移。这 样通过这个偏移就可以求出每个点的时间戳了。

不考虑多回波雷达,直接看时间戳处理部分:

1
2
3
4
5
6
7
8
9
10
::cartographer::common::Time timestamp = FromRos(msg.header.stamp);
if (!point_cloud.points.empty())
{
const double duration = point_cloud.points.back()[3];
// scan 最后一束激光的时间戳
timestamp += cartographer::common::FromSeconds(duration);
// 时间戳变成以最后一个测量数据为基准
for (Eigen::Vector4f& point : point_cloud.points)
point[3] -= duration;
}

这里把一帧激光的时间戳进行处理,有了绝对时间戳和相对时间戳两个概念。

我所用的雷达一帧有1040个点, 参数 time_increment: 1.73611115315e-05,     duration: 0.0180382

time_increment * 1040 = duration

timestamp: 634369239333347047

每个点的时间戳在处理前:

1
2
3
4
5
6
7
8
point.time:  0
point.time: 1.73611e-05
point.time: 3.47222e-05
point.time: 5.20833e-05
......
point.time: 0.0180035
point.time: 0.0180208
point.time: 0.0180382 # 同duration必然相同

然后每个时间戳减去 duration,这样最后一个点的时间戳就是0,之前的全是负值。 timestamp开始是第一个点的时间,最后处理为最后一个点的时间戳。

这里的时间戳计算是以雷达每束激光的 时间差相等和第一束激光的时间戳最小 为前提的,如果雷达的时间戳相反(逆向)或时间差不均匀,就会出错。

坐标如此处理:比如range为3,角度为45°,相应的坐标是(2.12132,2.12132,0)
转换示意图.png

HandleLaserScan 和 num_subdivisions_per_laser_scan 分段

接下来的HandleLaserScan按照参数num_subdivisions_per_laser_scan把点云分成了很多小段,每一段数据的时间偏移调整为以这一段的最后一条数据为基准,也就是把一帧scan拆成几段,每段按一个TimedPointCloudData发送,到了LocalTrajectoryBuilder2D::AddRangeData又把它们拼接起来,作为当前帧输入。

num_subdivisions_per_laser_scan一般设置为1,也就是不分段,可以不必仔细研究,直接看HandleRangefinder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void SensorBridge::HandleLaserScan(
const std::string& sensor_id, const carto::common::Time time,
const std::string& frame_id,
const carto::sensor::PointCloudWithIntensities& points)
{
if (points.points.empty()) return;
CHECK_LE(points.points.back()[3], 0); //最后一个点云数据的时间小于等于0
for (int i = 0; i != num_subdivisions_per_laser_scan_; ++i)
{
const size_t start_index =
points.points.size() * i / num_subdivisions_per_laser_scan_;
const size_t end_index =
points.points.size() * (i + 1) / num_subdivisions_per_laser_scan_;
// 每一小段
carto::sensor::TimedPointCloud subdivision(
points.points.begin() + start_index, points.points.begin() + end_index);
if (start_index == end_index)
{
continue;
}
// 参考分段中最后一个数据的时间,调整其他数据的时间
const double time_to_subdivision_end = subdivision.back()[3];
// subdivision_time is the end of the measurement so sensor::Collator will
// send all other sensor data first.
// 先确认当前的数据没有过时,如果分段的时间落后于记录值,将抛弃所对应的数据
const carto::common::Time subdivision_time =
time + carto::common::FromSeconds(time_to_subdivision_end);
auto it = sensor_to_previous_subdivision_time_.find(sensor_id);
if (it != sensor_to_previous_subdivision_time_.end() &&
it->second >= subdivision_time)
{
LOG(WARNING) << "Ignored subdivision of a LaserScan message from sensor "
<< sensor_id << " because previous subdivision time "
<< it->second << " is not before current subdivision time "
<< subdivision_time;
continue;
}
sensor_to_previous_subdivision_time_[sensor_id] = subdivision_time;
for (Eigen::Vector4f& point : subdivision)
{
point[3] -= time_to_subdivision_end;
}
CHECK_EQ(subdivision.back()[3], 0);
// 将分段数据喂给Cartographer
HandleRangefinder(sensor_id, subdivision_time, frame_id, subdivision);
}
}

HandleRangefinder

查询tracking_frame--->laser坐标系的变换, 把之前在laser坐标系的点云,转换为在tracking_frame坐标系(一般是base_footprint)的点云,类型变成时间点云数据TimedPointCloudData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void SensorBridge::HandleRangefinder(
const std::string& sensor_id, const carto::common::Time time,
const std::string& frame_id, const carto::sensor::TimedPointCloud& ranges)
{
// 查询在time时间,tracking_frame--->laser坐标系的变换,记录到对象 sensor_to_tracking
// 这里有时会卡住
const auto sensor_to_tracking =
tf_bridge_.LookupToTracking(time, CheckNoLeadingSlash(frame_id));
// 如果找到变换,通过trajectory_builder_ 添加传感器数据
if (sensor_to_tracking != nullptr)
{
trajectory_builder_->AddSensorData(
// 第一个转换是 TransformTimedPointCloud, 把数据从 sensor frame到tracking frame
// 第二个转换是从 TimedPointCloud 转到了 TimedPointCloudData
sensor_id, carto::sensor::TimedPointCloudData{
time, sensor_to_tracking->translation().cast<float>(),
carto::sensor::TransformTimedPointCloud(
ranges, sensor_to_tracking->cast<float>() ) } );
}
}

TfBridgeSensorBridge的构造函数里初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TfBridge {
public:
TfBridge(const std::string& tracking_frame,
double lookup_transform_timeout_sec, const tf2_ros::Buffer* buffer);
~TfBridge() {}

TfBridge(const TfBridge&) = delete;
TfBridge& operator=(const TfBridge&) = delete;

// 查询在time时间,frame_id--->tracking_frame 的变换,如果存在的话
// 也就是说 tracking_frame 是 target frame
std::unique_ptr<::cartographer::transform::Rigid3d> LookupToTracking(
::cartographer::common::Time time, const std::string& frame_id) const;

private:
const std::string tracking_frame_;
const double lookup_transform_timeout_sec_;
const tf2_ros::Buffer* const buffer_;
};


Voxel Filter和Adaptive Voxel Filter

VoxelFilter是把空间分为很多个立方体的栅格,一个栅格内可能有很多点, 但是只取落入栅格内的第一个点,而PCL库实现的体素滤波是用所有点的重心来代表整体

VoxelFilter

LocalTrajectoryBuilder2D::AddRangeData函数的最后:

1
2
3
4
5
6
7
return AddAccumulatedRangeData(
time,
TransformToGravityAlignedFrameAndFilter(
gravity_alignment.cast<float>() * range_data_poses.back().inverse(),
accumulated_range_data_),
gravity_alignment,
sensor_duration);

也就是第二个参数,这个很长名字的函数如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sensor::RangeData
LocalTrajectoryBuilder2D::TransformToGravityAlignedFrameAndFilter(
const transform::Rigid3f& transform_to_gravity_aligned_frame,
const sensor::RangeData& range_data) const
{
//裁剪数据,指定一定z轴范围内的数据
const sensor::RangeData cropped =
sensor::CropRangeData(sensor::TransformRangeData(
range_data, transform_to_gravity_aligned_frame),
options_.min_z(), options_.max_z());
return sensor::RangeData{
cropped.origin,
sensor::VoxelFilter(options_.voxel_filter_size()).Filter(cropped.returns),
sensor::VoxelFilter(options_.voxel_filter_size()).Filter(cropped.misses) };
}

简单说,这些滤波就是对点云数据的预处理:去除不合理的范围的点、合并相同位置的点等,从而减少点云数量,提高点云质量。

滤波的基本原理:将xyz分别除以分辨率,并取整,得到xyz所在cell的id(x,y,z),再将该三个整数分别放入3*32个字节的数中(很好的技巧,避免了大量的遍历和比较),插入voxelFileter::voxel_set_中,后面如果是第一次插入该数,则保留该点,如果之前存在该数,即意味着有其他点落在该网格内,不再保留该点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// using PointCloud = std::vector<RangefinderPoint>;
PointCloud VoxelFilter::Filter(const PointCloud& point_cloud)
{
PointCloud results;
for (const RangefinderPoint& point : point_cloud)
{
auto it_inserted =
voxel_set_.insert(IndexToKey(GetCellIndex(point.position)));
// 该体素之前没有没占据过,则插入,否则不插入
if (it_inserted.second)
results.push_back(point);
}
return results;
}

GetCellIndex: 3D点除以体素大小,得到所在的cell_index

IndexToKey: 将cell_index转换为一个32字节的数

AdaptiveVoxelFilter

adaptive_voxel_filter可以在最大边长 adaptive_voxel_filter.max_length 的限制下优化确定 voxel_filter_size 来实现目标的 .adaptive_voxel_filter.min_num_points

还是LocalTrajectoryBuilder2D::AddAccumulatedRangeData,这次看中间部分代码:

1
2
3
const sensor::PointCloud& filtered_gravity_aligned_point_cloud =
sensor::AdaptiveVoxelFilter(options_.adaptive_voxel_filter_options())
.Filter(gravity_aligned_range_data.returns);

也就是

1
2
3
4
5
PointCloud AdaptiveVoxelFilter::Filter(const PointCloud& point_cloud) const 
{
return AdaptivelyVoxelFiltered(
options_, FilterByMaxRange(point_cloud, options_.max_range()) );
}

AdaptiveVoxelFilter的构造函数按老规矩还是全赋值option,然后调用Filter,接着就是AdaptivelyVoxelFiltered函数: 再调用一次体素滤波,如果体素滤波后点数大于阈值,则返回; 如果小于阈值,则接着使用二分法进行体素滤波,使最终的点云数接近 min_num_points min_num_points <= 最终点云数 <= min_num_points x 1.1 网上说这里的二分查找计算量比较大,如果参数min_num_pointsmax_length 设置不好,会增加CPU,但我试了试发现影响很小。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
PointCloud AdaptivelyVoxelFiltered(
const proto::AdaptiveVoxelFilterOptions& options,
const PointCloud& point_cloud)
{
// 已经足够稀疏,默认200
if (point_cloud.size() <= options.min_num_points()) {
return point_cloud;
}
// 还是先调用VoxelFilter来滤波,参数 max_length
PointCloud result = VoxelFilter(options.max_length()).Filter(point_cloud);
// 按max_length进行滤波,还超过这个数,说明是这个参数能实现的最稀疏
if (result.size() >= options.min_num_points()) {
return result;
}

// 如果小于min_num_points,点云太稀疏,用于扫描匹配就不准了
// 说明上面的 max_length太大了,现在要使用二分法找一个合适的参数
// Search for a 'low_length' that is known to result in a sufficiently
// dense point cloud. We give up and use the full 'point_cloud' if reducing
// the edge length by a factor of 1e-2 is not enough.
for (float high_length = options.max_length();
high_length > 1e-2f * options.max_length();
high_length /= 2.f)
{
float low_length = high_length / 2.f;
result = VoxelFilter(low_length).Filter(point_cloud);
if (result.size() >= options.min_num_points())
{
// 二分查找法找到合适的过滤器
// low_length给出足够稠密的结果
// 当 edge length is at most 10% off 时停止
while ((high_length - low_length) / low_length > 1e-1f)
{
const float mid_length = (low_length + high_length) / 2.f;
const PointCloud candidate =
VoxelFilter(mid_length).Filter(point_cloud);
// 点数又多了,增加边长,让 low_length 变大
if (candidate.size() >= options.min_num_points()) {
low_length = mid_length;
result = candidate;
}
// 点数偏少,让high_length变小
else {
high_length = mid_length;
}
}
return result;
}
}
return result;
}

举例:刚开始,点云数量390,Voxel Filter之后只有58个,而参数min_num_points默认50,此时就返回了。

参数min_num_points改为100,点云数量390,Voxel Filter之后只有58个,进入二分法过滤,最后的点云数量为110.


前端 1. 入口 AddRangeData

在Cartographer中机器人的运动轨迹是由一个个前后串联的节点(TrajectionNode)构成的。每当有新的扫描数据输入, LocalTrajectoryBuilder2D都会 先通过scan matcher生成一个MatchingResult的对象,但是它的insertion_result字段是留空的。 只有当通过motion filter成功插入到子图中之后,才会将插入的结果记录下来, 于此同时子图就得到了更新。

LocalTrajectoryBuilder2D类的成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct InsertionResult {
// Node数据
std::shared_ptr<const TrajectoryNode::Data> constant_data;
//该Node数据插入的两个submap
std::vector<std::shared_ptr<const Submap2D>> insertion_submaps;
};

struct MatchingResult
{
common::Time time; // 扫描匹配发生的时间
// tracking 坐标系在Local SLAM坐标系下的位姿, 需要乘以
// Local SLAM坐标系到Global SLAM坐标系的转换 才能转到全局坐标系
transform::Rigid3d local_pose;
// tracking 坐标系下的点云(即LocalSLAM坐标系下的点云)
sensor::RangeData range_data_in_local;
// 如果被 motion filter 过滤,就是 nullptr
std::unique_ptr<const InsertionResult> insertion_result;
};

Local SLAM的业务主线是LocalTrajectoryBuilder2D::AddRangeData,它在核心函数GlobalTrajectoryBuilder::AddSensorData里调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 把来自不同传感器的TimePointCloudData进行同步。TimePointCloudData是带时间的点云数据
// 对于2d情况,第3个元素始终为0,第4个元素表示时间
// 时间以s为单位,以最后一个点的捕获时间为0,越早捕获的点时间值越大
std::unique_ptr<LocalTrajectoryBuilder2D::MatchingResult>
LocalTrajectoryBuilder2D::AddRangeData(
const std::string& sensor_id,
const sensor::TimedPointCloudData& unsynchronized_data)
{
// 把输入的索引和数据记录在range_data_collator_中,同时做了时间同步
// RangeDataCollator::AddRangeData
// TimedPointCloudOriginData range_data_collator_
auto synchronized_data =
range_data_collator_.AddRangeData(sensor_id, unsynchronized_data);
if (synchronized_data.ranges.empty() )
{
LOG(INFO) << "Range data collator filling buffer.";
return nullptr;
}
// 记录下同步时间
const common::Time& time = synchronized_data.time;
// 如果不使用IMU,那么直接初始化 extrapolator
// 取点云获取的时间为基准
if (!options_.use_imu_data())
InitializeExtrapolator(time);

// 如果使用IMU进行估计,那么将在接收到IMU消息之后,再初始化 extrapolator
if (extrapolator_ == nullptr)
{
LOG(INFO) << "Extrapolator not yet initialized.";
return nullptr;
}
CHECK(!synchronized_data.ranges.empty());
// 确保最后一个数据的时间偏移量 <= 0
CHECK_LE(synchronized_data.ranges.back().point_time.time, 0.f);
// 获取第一个数据点的绝对时间,第一个点的时间就等于点云集获取的时间加上第一个点记录的相对时间
// 将它与 extrapolator的时间对比,来判定 extrapolator 是否完成了初始化
const common::Time time_first_point =
time +
common::FromSeconds(synchronized_data.ranges.front().point_time.time);
// 上一帧位姿如果没有计算完,则还在初始化的过程中
if (time_first_point < extrapolator_->GetLastPoseTime())
{
LOG(INFO) << "Extrapolator is still initializing.";
return nullptr;
}

第一次收到传感器数据时, GetLastPoseTimetime = synchronized_data.time相同。其余情况,都应当满足关系: time = synchronized_data.time > time_first_point > extrapolator_->GetLastPoseTime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::vector<transform::Rigid3f> range_data_poses;
range_data_poses.reserve(synchronized_data.ranges.size() );
bool warned = false;
for (const auto& range : synchronized_data.ranges)
{
common::Time time_point = time + common::FromSeconds(range.point_time.time);
if (time_point < extrapolator_->GetLastExtrapolatedTime() )
{
if (!warned)
{
LOG(ERROR)
<< "Timestamp of individual range data point jumps backwards from "
<< extrapolator_->GetLastExtrapolatedTime() << " to " << time_point;
warned = true;
}
time_point = extrapolator_->GetLastExtrapolatedTime();
}
// 根据每个点的时间戳,估计每个点对应的位置并进行缓存
// 根据时间戳,计算 tracking坐标系在local坐标系下的位姿
range_data_poses.push_back(
extrapolator_->ExtrapolatePose(time_point).cast<float>() );
}

处理运动畸变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 如果还没有累积过扫描数据,就重置对象 accumulated_range_data_
// sensor::RangeData accumulated_range_data_;
// 它就是后面插入子图的 scan 的来源
if (num_accumulated_ == 0)
accumulated_range_data_ = sensor::RangeData{ {}, {}, {} };
/************* 处理运动畸变 ****************/
// Drop any returns below the minimum range and convert
// returns beyond the maximum range into misses.
for (size_t i = 0; i < synchronized_data.ranges.size(); ++i)
{
const sensor::TimedRangefinderPoint& hit =
synchronized_data.ranges[i].point_time;
/* 下面两行是左乘机器人位姿,将tracking坐标系下的激光测量的hit点位姿
和传感器位姿转换到 local map 的坐标系下*/
const Eigen::Vector3f origin_in_local =
range_data_poses[i] *
synchronized_data.origins.at(synchronized_data.ranges[i].origin_index);
// 这里是处理运动畸变的最关键部分
sensor::RangefinderPoint hit_in_local =
range_data_poses[i] * sensor::ToRangefinderPoint(hit);
// 计算局部地图下的测量距离
const Eigen::Vector3f delta = hit_in_local.position - origin_in_local;
const float range = delta.norm(); // 该向量的模

// 完成 hit集合 和 miss集合
// 所有低于min_range的hit点不考虑,但大于max_range的点做处理后放入miss集
if (range >= options_.min_range() )
{
if (range <= options_.max_range())
// 如果该向量的模在合理范围内,则压入returns点集
accumulated_range_data_.returns.push_back(hit_in_local);
// delta多了系数 missing_data_ray_length() / range
/* 这个公式的作用是在miss方向上距离原点 misses_data_ray_length长度处,
选择一个点作为miss点加入miss集合,后面在更新submap的概率的时候,
原点到return射线中间的点和到 misses 射线的点都是通过miss_table来查询概率。
对超过最大距离的射线,选择一个中间的距离点,到这个中间距离点的区域,
我们都认为是空白区域 */
else
{
hit_in_local.position =
origin_in_local + options_.missing_data_ray_length() / range * delta;
accumulated_range_data_.misses.push_back(hit_in_local);
}
}
}

运动畸变的来源:雷达静止时,扫描结果没什么问题。雷达放在车上运动时,由于雷达扫描频率不高(比如20Hz),一些激光点就会有偏差。 注意不是指车运动时的激光往返距离的不同导致的, 激光速度为光速,车速与之相比可以忽略不计。

如上图所示,雷达的扫描是从右往左,应当扫描的结果是AB,但是扫描到B时,雷达已经运动了一段距离(假设匀速运动),此时扫描的是 但是扫描的结果仍然算作运动前的坐标系里,也就是,最终的结果是, 这就造成了畸变。
向障碍物前进,获得的障碍距离偏小;远离障碍物,获得的障碍距离偏大。注意雷达扫描方向的不同,畸变的情况也不同。

雷达产生运动畸变,而相机不同,不管运动不运动都有畸变。实际中,平移产生的畸变非常小,可以忽略不计。但旋转产生的畸变,不能忽略了。

运动补偿通常有几种做法
1、如果有高频里程记,可以获取每个点相对起始扫描时刻的位姿
2、如果有imu,可以求出每个点相对起始点的旋转
3、如果没有其他传感器,可以使用匀速模型假设,使用上一个帧间里程记的结果作为当前两帧之间的运动,同时假设当前帧也是匀速运动,也可以估计出每个点相对起始时刻的位姿

cartographer对每个点的坐标左乘range_data_poses[i],而它来自位姿估计器,后面可以看到,用了1和2两种方法。

代码中把点云从tracking坐标系转到local坐标系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  // 有了一帧有效点云数据了
++num_accumulated_;
// 每当累积的有效激光帧数 超过了配置值,一般为1
if (num_accumulated_ >= options_.num_accumulated_range_data() )
{
const common::Time current_sensor_time = synchronized_data.time;
absl::optional<common::Duration> sensor_duration;
if (last_sensor_time_.has_value())
{
sensor_duration = current_sensor_time - last_sensor_time_.value();
}
last_sensor_time_ = current_sensor_time;
num_accumulated_ = 0; // 计数清零
// 获取重力的方向,后面将点云z轴和重力方向对齐
const transform::Rigid3d gravity_alignment = transform::Rigid3d::Rotation(
extrapolator_->EstimateGravityOrientation(time) );

// 计算origin,在插入子图时非常重要
// 最后一个点的时间戳估计的坐标,作为原点
accumulated_range_data_.origin = range_data_poses.back().translation();
return AddAccumulatedRangeData(
time,
// 点云对齐到重力方向后,对return集合体素化滤波
// 点云变换到原点处
TransformToGravityAlignedFrameAndFilter(
gravity_alignment.cast<float>() * range_data_poses.back().inverse(),
accumulated_range_data_),

gravity_alignment,
sensor_duration );
}
return nullptr;
}

origin会有不断增加,因为车是动的,点云不断更新。origin_index是在点云时间同步那里赋值的,可以不关心细节。
range_data_poses是tracking坐标系在local坐标系的坐标,也就是二者的坐标系变换。大小是激光点的个数,或者稍微少一点

最后不仅把点云转到local坐标系和重力对齐,还把点云变换到原点处 点云转到local坐标系原点,这时候点云有可能是斜着的,先通过机器人的姿态把点云变成水平的,然后再对射程做过滤

TransformToGravityAlignedFrameAndFilter

按重力方向对点云数据进行变换,让点云的z轴和重力方向重合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
sensor::RangeData
LocalTrajectoryBuilder2D::TransformToGravityAlignedFrameAndFilter(
const transform::Rigid3f& transform_to_gravity_aligned_frame,
const sensor::RangeData& range_data) const
{
const sensor::RangeData cropped =
// 限定点云z坐标
sensor::CropRangeData(sensor::TransformRangeData(
range_data, transform_to_gravity_aligned_frame),
options_.min_z(), options_.max_z());
// 对 returns 和 misses点云做体素滤波
return sensor::RangeData{
cropped.origin,
sensor:: (options_.voxel_filter_size()).Filter(cropped.returns),
sensor::VoxelFilter(options_.voxel_filter_size()).Filter(cropped.misses) };
}

RangeData TransformRangeData(const RangeData& range_data,
const transform::Rigid3f& transform)
{
return RangeData{
transform * range_data.origin,
TransformPointCloud(range_data.returns, transform),
TransformPointCloud(range_data.misses, transform),
};
}
// 对点云中每一个点做变换
PointCloud TransformPointCloud(const PointCloud& point_cloud,
const transform::Rigid3f& transform)
{
PointCloud result;
result.reserve(point_cloud.size() );
for (const RangefinderPoint& point : point_cloud)
{
result.emplace_back(transform * point);
}
return result;
}

LocalTrajectoryBuilder2D是一个没有闭环处理的完整的slam过程,修改submap的帧的阈值(无限大),则完成了与开源hector slam算法一致的功能。


(三) MapBuilder类

MapBuilder继承了抽象类MapBuilderInterface,是cartographer算法的核心类.MapBuilder包括了两个部分,其中TrajectoryBuilder用于Local Submap的建立与维护;PoseGraph部分用于Loop Closure.

源码中使用的变量是map_builder_,属于MapBuilder类型,它的赋值在MapBuilderBridge的构造函数;变量map_builder_bridge_的赋值又在Node的构造函数;最终是在node_main.cc中的

1
2
3
auto map_builder =
cartographer::common::make_unique<cartographer::mapping::MapBuilder>(
node_options.map_builder_options );

具体怎么实现不用管,知道这是个智能指针就行

成员变量

MapBuilder私有成员变量

1
2
3
4
5
6
7
8
9
10
11
const proto::MapBuilderOptions options_;	//MapBuilder的配置项
common::ThreadPool thread_pool_; //线程池,应该是为每一条trajectory都单独开辟一个线程
// 一个PoseGraph的智能指针
std::unique_ptr<PoseGraph> pose_graph_;
//收集传感器数据的智能指针
unique_ptr<sensor::CollatorInterface> sensor_collator_;
//一个向量,管理所有的TrajectoryBuilderInterface;应该是每一个trajectory对应了该向量的一个元素
// 每一个TrajectoryBuilder对应了机器人运行了一圈
vector<std::unique_ptr<mapping::TrajectoryBuilderInterface>> trajectory_builders_;
//与每个 TrajectoryBuilderInterface 相对应的配置项
vector<proto::TrajectoryBuilderOptionsWithSensorIds> all_trajectory_builder_options_;

线程池和pose_graph_在这里。 由此可见MapBuilder构造函数里,后端的线程就已经开始跑了

trajectory是机器人跑一圈时的轨迹,在这其中需要记录和维护传感器的数据。根据这个trajectory上传感器收集的数据,我们可以逐步构建出栅格化的地图Submap,但这个submap会随着时间或trajectory的增长而产生误差累积。
PoseGraph是用来进行全局优化,将所有的Submap紧紧tie在一起,构成一个全局的Map,消除误差累积。

构造函数

省略3D的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MapBuilder::MapBuilder(const proto::MapBuilderOptions& options)
: options_(options), thread_pool_(options.num_background_threads())
{
// 加载配置,注意 optimization::OptimizationProblem2D 对象
pose_graph_ = absl::make_unique<PoseGraph2D>(
options_.pose_graph_options(),
absl::make_unique<optimization::OptimizationProblem2D>(
options_.pose_graph_options().optimization_problem_options() ),
&thread_pool_);
// 构建修正器
if (options.collate_by_trajectory())
sensor_collator_ = absl::make_unique<sensor::TrajectoryCollator>();
else
sensor_collator_ = absl::make_unique<sensor::Collator>();
}

在lua文件中找不到collate_by_trajectory这一项,默认没有开启,但在map_builder_options.proto中可以找到,如果我们想要配置这个选项,只需要在lua文件中添加MAP_BUILDER.collate_by_trajectory = true

MapBuilder类在构造时会对线程池, 位姿图进行初始化, 在添加新轨迹时会对前端类的对象进行初始化, 初始化之后才会去判读这个轨迹是否存在初始位姿.

AddTrajectoryBuilder

这个函数可以说是cartographer中最重要的函数之一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int MapBuilder::AddTrajectoryBuilder(
const std::set<SensorId>& expected_sensor_ids,
const proto::TrajectoryBuilderOptions& trajectory_options,
LocalSlamResultCallback local_slam_result_callback)
{
// 当有一个新的trajectory时,以向量的size值作为新的trajectory,加入到trajectory_builders_中
const int trajectory_id = trajectory_builders_.size();
// 省略 3D 部分
/*LocalTrajectoryBuilder2D是不带Loop Closure的Local Slam, 包含了Pose Extrapolator, Scan Matching等
这个类并没有继承TrajectoryBuilderInterface, 并不是一个TrajectoryBuilderInterface的实现
而只是一个工具类,构造函数全是配置的赋值*/
std::unique_ptr<LocalTrajectoryBuilder2D> local_trajectory_builder;
if (trajectory_options.has_trajectory_builder_2d_options() )
{
local_trajectory_builder = absl::make_unique<LocalTrajectoryBuilder2D>(
trajectory_options.trajectory_builder_2d_options(),
// 找出expected_sensor_ids中的雷达的话题,比如scan1,scan2
SelectRangeSensorIds(expected_sensor_ids) );
}
DCHECK(dynamic_cast<PoseGraph2D*>(pose_graph_.get()) );
/* 类CollatedTrajectoryBuilder 继承了接口 TrajectoryBuilderInterface,这个才是真正的
轨迹跟踪器,使用 sensor::CollatorInterface 接口来收集传感器数据,具体的类在 MapBuilder 构造函数最后
CollatedTrajectoryBuilder是 sensor_bridges_构造函数中的对应最后一个参数的类型 */
trajectory_builders_.push_back( absl::make_unique<CollatedTrajectoryBuilder>(
trajectory_options, sensor_collator_.get(), trajectory_id,
expected_sensor_ids,
/* 函数返回类型实际是 std::unique_ptr<GlobalTrajectoryBuilder>,它实际上是一个模板类
其模板列表中的 LocalTrajectoryBuilder2D 和 PoseGraph2D 分别是前端和后端的两个核心类型
local_slam_result_callback 前端数据更新后的回调函数 */

// 将2D前端和位姿图打包传入 CollatedTrajectoryBuilder
CreateGlobalTrajectoryBuilder2D(
std::move(local_trajectory_builder), trajectory_id,
static_cast<PoseGraph2D*>(pose_graph_.get() ),
local_slam_result_callback) ) );

一个MapBuilder的类对应了一次建图过程,在整个建图过程中,用于全局优化的PoseGraph的对象只有一个,即pose_graph_,而这个变量是在构造函数中就生成了。

每生成一个trajectory时都是调用AddTrajectoryBuilder来创建的。一个trajectory对应了机器人运行一圈,trajectory_id取的是trajectory_builders_.size(),所以开始是0.

在图建好后机器人可能多次运行,每一次运行都是新增一条trajectory,trajectory_id就会+1. 因此,需要动态地维护一个trajectory的列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  // 如果开启了纯定位模式 
MaybeAddPureLocalizationTrimmer(trajectory_id, trajectory_options, pose_graph_.get());
/* 如果开始建图之前已经有了初始位置,将初始位置提供给pose_graph_对象
比如说,我们检测到了一个Landmark。那么这时,我们可以新增加一条trajectory
然后根据机器人与Landmark之间的相对位姿推算机器人相对于世界坐标系的相对位姿
以该位姿作为新增加的trajectory的初始位姿。这样在检测到Landmark时就能有效降低累积误差*/
if (trajectory_options.has_initial_trajectory_pose())
{
const auto& initial_trajectory_pose =
trajectory_options.initial_trajectory_pose();
pose_graph_->SetInitialTrajectoryPose(
trajectory_id, initial_trajectory_pose.to_trajectory_id(),
transform::ToRigid3(initial_trajectory_pose.relative_pose()),
common::FromUniversal(initial_trajectory_pose.timestamp()) );
}
// 将轨迹跟踪器的配置信息和传感器配置信息保存到容器all_trajectory_builder_options_中
proto::TrajectoryBuilderOptionsWithSensorIds options_with_sensor_ids_proto;
for (const auto& sensor_id : expected_sensor_ids)
{
*options_with_sensor_ids_proto.add_sensor_id() = ToProto(sensor_id);
}
*options_with_sensor_ids_proto.mutable_trajectory_builder_options() =
trajectory_options;

all_trajectory_builder_options_.push_back(options_with_sensor_ids_proto);
CHECK_EQ(trajectory_builders_.size(), all_trajectory_builder_options_.size());
return trajectory_id;
}

has_initial_trajectory_pose

其他函数

  • SerializeState: 保存地图到pbstream,序列化当前状态到一个proto流中

  • LoadState: 加载pbstream地图,从一个proto流中加载SLAM状态,逐条trajectory恢复,恢复trajectory上的节点间的约束,恢复Submap

  • FinishTrajectory: 结束指定id的轨迹,调用sensor_collator_->FinishTrajectorypose_graph_->FinishTrajectory

  • SubmapToProto: 根据指定的submap_id来查询submap,把结果放到SubmapQuery::Response中。如果出现错误,返回error string; 成功则返回empty string. 对应服务kSubmapQueryServiceName, 调用关系如下:

Node::HandleSubmapQuery
      |
MapBuilderBridge::HandleSubmapQuery
      |
MapBuilder::SubmapToProto


(二) Node的AddTrajectory函数

Cartographer源码架构图

接上一篇,Node::StartTrajectoryWithDefaultTopics实际就一个Node::AddTrajectory,它的流程图:
AddTrajectory.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const std::set<cartographer::mapping::TrajectoryBuilderInterface::SensorId>
expected_sensor_ids = ComputeExpectedSensorIds(options, topics);
const int trajectory_id =
map_builder_bridge_.AddTrajectory(expected_sensor_ids, options);

AddExtrapolator(trajectory_id, options);
AddSensorSamplers(trajectory_id, options);
LaunchSubscribers(options, topics, trajectory_id);
is_active_trajectory_[trajectory_id] = true;
for (const auto& sensor_id : expected_sensor_ids)
{ // unordered_set
subscribed_topics_.insert(sensor_id.id);
}
return trajectory_id;

Node::ComputeExpectedSensorIds

函数返回的是std::set<TrajectoryBuilderInterface::SensorId> expected_topics;类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct SensorId
{
enum class SensorType {
RANGE = 0,
IMU,
ODOMETRY,
FIXED_FRAME_POSE,
LANDMARK,
LOCAL_SLAM_RESULT
};
SensorType type;
std::string id;
}

它记录的是所有传感器的类型和对应id,id是一个字符串,记录了传感器所对应的ROS主题名称。 如果有多个雷达,id就是scan1,scan2……

至于为什么这样命名,由下面的函数规定,字符串在cartographer_ros\node_constants.h中赋值

1
2
3
4
5
6
7
8
9
10
11
cartographer_ros_msgs::SensorTopics DefaultSensorTopics() {
cartographer_ros_msgs::SensorTopics topics;
topics.laser_scan_topic = kLaserScanTopic;
topics.multi_echo_laser_scan_topic = kMultiEchoLaserScanTopic;
topics.point_cloud2_topic = kPointCloud2Topic;
topics.imu_topic = kImuTopic;
topics.odometry_topic = kOdometryTopic;
topics.nav_sat_fix_topic = kNavSatFixTopic;
topics.landmark_topic = kLandmarkTopic;
return topics;
}

MapBuilderBridge::AddTrajectory

两个参数是const std::set<TrajectoryBuilderInterface::SensorId>& expected_sensor_idsconst TrajectoryOptions& trajectory_options

expected_sensor_ids记录了用于建图的所有传感器名称和类型

MapBuilderBridge的构造函数就是加载配置和map_builder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 添加一个轨迹跟踪器,同时将构建成功的索引返回
const int trajectory_id = map_builder_->AddTrajectoryBuilder(
expected_sensor_ids, trajectory_options.trajectory_builder_options,
// 这个参数相当于注册了一个回调函数 OnLocalSlamResult , 用于响应map_builder_完成
// 一个局部SLAM 或者 说是成功构建了一个子图的事件
::std::bind(&MapBuilderBridge::OnLocalSlamResult, this,
::std::placeholders::_1, ::std::placeholders::_2,
::std::placeholders::_3, ::std::placeholders::_4,
::std::placeholders::_5) );

LOG(INFO) << "Added trajectory with ID '" << trajectory_id << "'.";

// std::unordered_map<int, std::unique_ptr<SensorBridge>> sensor_bridges_;
// 检查 trajectory_id, 确保之前没有使用过
CHECK_EQ(sensor_bridges_.count(trajectory_id), 0);
// SensorBridge将ROS的消息转换成 Cartographer 中的传感器数据类型
// 前面都是参数,最后的参数是Cartographer的一个核心对象,TrajectoryBuilderInterface指针
// 通过sensor_bridge对象转换后的数据都是通过它喂给Cartographer的
sensor_bridges_[trajectory_id] =
cartographer::common::make_unique<SensorBridge>(
trajectory_options.num_subdivisions_per_laser_scan,
trajectory_options.tracking_frame,
node_options_.lookup_transform_timeout_sec, tf_buffer_,
// 实际类型是 CollatedTrajectoryBuilder
map_builder_->GetTrajectoryBuilder(trajectory_id) );
// 轨迹相关的配置保存到容器对象trajectory_options_中并检查后,返回刚刚生成的索引trajectory_id
auto emplace_result = trajectory_options_.emplace(trajectory_id, trajectory_options);
CHECK(emplace_result.second == true);
return trajectory_id;

MapBuilder::AddTrajectoryBuilderGetTrajectoryBuilder到后面继续看

回调函数 OnLocalSlamResult

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void MapBuilderBridge::OnLocalSlamResult(
const int trajectory_id,
const ::cartographer::common::Time time, // 更新子图的时间
const Rigid3d local_pose, // 子图的参考位置
::cartographer::sensor::RangeData range_data_in_local, // 参考位置下的扫描数据
const std::unique_ptr<const ::cartographer::mapping::
TrajectoryBuilderInterface::InsertionResult> )
{
// LocalSlamData是定义在 MapBuilderBridge 内部的结构体,用于记录局部SLAM反馈的状态
std::shared_ptr<const TrajectoryState::LocalSlamData> local_slam_data =
std::make_shared<TrajectoryState::LocalSlamData>(
TrajectoryState::LocalSlamData{time, local_pose,
std::move(range_data_in_local)} );
cartographer::common::MutexLocker lock(&mutex_);

/* std::unordered_map<int, std::shared_ptr<const TrajectoryState::LocalSlamData>>
trajectory_state_data_ GUARDED_BY(mutex_);*/
trajectory_state_data_[trajectory_id] = std::move(local_slam_data);
}

最后赋值给了trajectory_state_data_,唯一应用的地方在MapBuilderBridge::GetTrajectoryStates()

可以对此函数做修改,添加日志等功能

Node::AddExtrapolator

1
2
3
4
5
6
7
constexpr double kExtrapolationEstimationTimeSec = 0.001;  // 1 ms
// 省略一行:把lua文件的 imu_gravity_time_constant 参数赋给变量 gravity_time_constant
extrapolators_.emplace(
std::piecewise_construct, std::forward_as_tuple(trajectory_id),
std::forward_as_tuple(
::cartographer::common::FromSeconds(kExtrapolationEstimationTimeSec),
gravity_time_constant) );

添加用于位姿插值的对象extrapolators_, 类型std::map<int, PoseExtrapolator> extrapolators_;

Node::AddSensorSamplers

1
2
3
4
5
6
sensor_samplers_.emplace(
std::piecewise_construct, std::forward_as_tuple(trajectory_id),
std::forward_as_tuple(
options.rangefinder_sampling_ratio, options.odometry_sampling_ratio,
options.fixed_frame_pose_sampling_ratio, options.imu_sampling_ratio,
options.landmarks_sampling_ratio) );

类型std::unordered_map<int, TrajectorySensorSamplers> sensor_samplers_;

对cartographer中的采样器(fixed_ratio_sampler.cc)的封装,用一个计数器来按照一个指定的频率对原始的数据进行降采样,采样频率可以通过轨迹参数文件来配置

LaunchSubscribers

完成传感器消息的订阅,在传感器数据的作用下驱动系统运转,进而完成位姿估计和建图的任务。详细分析见第四篇


(一) 从node_main.cc开始

先看main函数,省略google库的部分:

1
2
3
4
5
6
::ros::init(argc, argv, "cartographer_node");
::ros::start();

cartographer_ros::ScopedRosLogSink ros_log_sink;
cartographer_ros::Run();
::ros::shutdown();

实际上就是Run函数,流程如下:
Run函数流程.png

1
2
3
4
5
6
7
8
constexpr double kTfBufferCacheTimeInSeconds = 10.;
tf2_ros::Buffer tf_buffer{::ros::Duration(kTfBufferCacheTimeInSeconds)};
tf2_ros::TransformListener tf(tf_buffer);
// 从lua文件加载配置
NodeOptions node_options;
TrajectoryOptions trajectory_options;
std::tie(node_options, trajectory_options) =
LoadOptions(FLAGS_configuration_directory, FLAGS_configuration_basename);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 核心变量: Cartographer的地图构建器
auto map_builder =
cartographer::common::make_unique<cartographer::mapping::MapBuilder>(
node_options.map_builder_options);
Node node(node_options, std::move(map_builder), &tf_buffer);
// 加载现有地图的情况
if (!FLAGS_load_state_filename.empty()) {
node.LoadState(FLAGS_load_state_filename, FLAGS_load_frozen_state);
}
// 核心函数
if (FLAGS_start_trajectory_with_default_topics) {
node.StartTrajectoryWithDefaultTopics(trajectory_options);
}
::ros::spin();

注意:MapBuilder类的对象在这里就实例化了,也是唯一的对象,而不是在以为的MapBuilderBridge

1
2
3
4
5
6
node.FinishAllTrajectories();
node.RunFinalOptimization();

if (!FLAGS_save_state_filename.empty()) {
node.SerializeState(FLAGS_save_state_filename);
}

显然我们需要看的函数是StartTrajectoryWithDefaultTopics

node.FinishAllTrajectories();node.RunFinalOptimization();是停止建图才执行的,一直到后端优化那里再分析。

proto机制

编写应用程序的时候,往往需要将程序的某些数据存储在内存中,然后将其写入某个文件或是将它传输到网络中的另一台计算机上以实现通讯。这个将程序数据转化成能被存储并传输的格式的过程被称为序列化,而它的逆过程则可被称为反序列化。简单来说,序列化:将对象变成字节流的形式传出去。反序列化:从字节流恢复成原来的对象。

为什么要序列化:假如你有一个数据结构,里面存储的数据是经过很多其它数据通过非常复杂的算法生成的,由于数据量很大,算法又复杂,因此生成该数据结构所用数据的时间可能要很久(也许几个小时),生成该数据结构后又要用作其它的计算,那么你在调试阶段,每次运行个程序,就光生成数据结构就要花上这么长的时间,无疑代价是非常大的。如果你确定生成数据结构的算法不会变,那么就可以通过序列化技术生成数据结构数据存储到磁盘上,下次重新运行程序时只需要从磁盘上读取该对象数据即可,所花时间只有读一个文件的时间,节省了我们的开发时间。

Protobuf性能好,效率高;拥有代码生成机制,数据解析类自动生成;支持很多语言,包括C++、Java、Python;同时也是跨平台的,所以得到了广泛的应用。正常情况下你需要定义proto文件,然后使用IDL编译器编译成你需要的语言。目前proto存在2和3两个版本,最好用3.与2相比,3的改变有:移除了required字段、移除了缺省值等

proto的语法见proto文件语法Protobuf 终极教程

生成C++代码的命令是: protoc -I=. --cpp_out=. test.proto. -I指定protoc的搜索import的proto的文件夹,cpp_out是生成的目录,生成的文件类型是pb.hpb.cc。 对于Cartographer,proto文件在源码的cartographer/cartographer/,生成的pb.h文件在/usr/local/include/cartographer

以后会发现有些参数不在lua里,但在proto里,proto是 Cartographer原本的参数描述的方式,lua是catographer_ros的封装。proto定义了参数,lua具体描述参数的数值,他俩就跟class和实例化的对象一样

我尝试在lua中添加proto中特有的参数,比如collate_by_trajectory,结果运行Cartographer还是失败,可能还是不兼容。


ROS中的Lazy发布和订阅

之前看depthimage_to_laserscan包,发现它的订阅发布采用的是所谓 lazy模式。今天研究了一下,发现并不复杂。先看advertise函数的一个原型:

1
2
3
4
5
6
7
Publisher ros::NodeHandle::advertise(const std::string& topic,
uint32_t queue_size,
const SubscriberStatusCallback & connect_cb,
const SubscriberStatusCallback & disconnect_cb = SubscriberStatusCallback(),
const VoidConstPtr & tracked_object = VoidConstPtr(),
bool latch = false
)

原来有三个原型,我们常用的是第一个,这个是第二个,第三个就极其罕见了,形参是个AdvertiseOptions类型。

话题有订阅者时,发布者会触发connect_cb函数,停止订阅时又触发disconnect_cb函数。如果让它们做类成员函数,就使用Boost::bind形式,tracked_object暂时不用。所以程序可以这样写:

1
2
3
4
5
6
7
8
9
10
void connectCb(const ros::SingleSubscriberPublisher& singlePub)
{
ROS_INFO("Subscriber Name: %s", singlePub.getSubscriberName().c_str());
}
void disconnectCb(const ros::SingleSubscriberPublisher& singlePub)
{
ROS_INFO("disconnectCb");
}
......
ros::Publisher pub = nh.advertise<std_msgs::Int8>("topic", 50, connectCb,disconnectCb);

可以用rostopic echo topic测试


再来看depthimage_to_laserscan的程序,构造函数里是这样的:

1
2
3
4
pub_ = n.advertise<sensor_msgs::LaserScan>("scan", 10, 
boost::bind(&DepthImageToLaserScanROS::connectCb, this, _1),
boost::bind(&DepthImageToLaserScanROS::disconnectCb, this, _1)
);

它是把两个函数用成员函数的形式,connectCb是这样的:
1
2
3
4
5
6
if (!sub_ && pub_.getNumSubscribers() > 0)
{
ROS_DEBUG("Connecting to depth topic.");
image_transport::TransportHints hints("raw", ros::TransportHints(), pnh_);
sub_ = it_.subscribeCamera("/camera/depth/image_raw", 10, &DepthImageToLaserScanROS::depthCb, this, hints);
}

depthCb是:

1
2
sensor_msgs::LaserScanPtr scan_msg = dtl_.convert_msg(depth_msg, info_msg);
pub_.publish(scan_msg);

显然逻辑是这样的:有其他节点订阅scan话题时,发布者里才订阅image_raw话题,然后才会发布scan话题的实际消息,所谓的lazy就是scan话题的消息发布

disconnectCb里,如果另一个节点不再订阅scan话题,本节点就会sub_.shutdown();;如果再有订阅scan,又可以通过connectCb使用sub_,设计确实很巧妙

参考:
Publisher advertise
SingleSubscriberPublisher


landmark的使用 (一) 原理

landmark是路标点,apriltag、二维码等强特征,static landmark 的作用是用于快速定位的。

对landmark的观测是相对于Cartographer的tracking frame,用户负责发布话题landmark,类型cartographer_ros_msgs/LandmarkList. cartographer订阅landmark之后,会发布话题/landmark_poses_list,类型visualization_msgs/MarkerArray,在rviz上显示出来,用于确认cartographer计算出landmark在map坐标系中的坐标。 Cartographer负责建立landmark的约束,与栅格地图一起优化。

机器人应当一直运动以产生trajectory优化,如果机器人不动,无法触发优化,landmark无法加入图优化框架。landmark的观测应当是相对于 tracking frame

Try the following to see an impressive effect (only for testing):
假如进行以下步骤:

  1. 将 landmark 的权重设置的很大,比如 1e8
  2. 观测一个id为0的 landmark
  3. 把机器人开到另一个地方
  4. 又一个id为0的 landmark

第二次观测显然是错误的,但是由于landmark的极高权重, 机器人位姿会跳跃

lua 设置

设置 use_landmarks = true,话题landmark应具备一定频率,默认要求最低10Hz。话题的消息可以是空的,但必须提供,因为Cartographer严格给传感器数据按时间排序,使landmarks deterministic. 但是也可以设置TRAJECTORY_BUILDER.collate_landmarks = false,这样landmark就是non-deterministic而且是非阻塞的。GPS也是同理。

landmarks_sampling_ratio = 1 landmark观测的权重比,当某个观测不太靠谱的时候,可以把1改为较小的权重,例如0.5,从而减小对整体优化的错误影响。

如果只获得landmark的相对位置,没有朝向,那就把landmark rotation weight设置为0

每个landmark的ID和坐标应当是unique而且是一一对应

源码

要知道tag在相机坐标系的位置,相机坐标系和base_link之间的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Node::HandleLandmarkMessage(
const int trajectory_id, const std::string& sensor_id,
const cartographer_ros_msgs::LandmarkList::ConstPtr& msg)
{
absl::MutexLock lock(&mutex_);
if (!sensor_samplers_.at(trajectory_id).landmark_sampler.Pulse())
return;

map_builder_bridge_.sensor_bridge(trajectory_id)->HandleLandmarkMessage(sensor_id, msg);
// 添加下面代码
geometry_msgs::TransformStamped stamped_transform;

stamped_transform.header.stamp = ros::Time::now();
stamped_transform.header.frame_id = "base_link";
stamped_transform.child_frame_id = "QR_Code";
stamped_transform.transform = ToGeometryMsgTransform(ToRigid3d(msg->landmarks[0].tracking_from_landmark_transform).inverse() );

tf_broadcaster_.sendTransform(stamped_transform);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void Node::PublishLandmarkPosesList(
const ::ros::WallTimerEvent& unused_timer_event)
{
// landmark话题必须有订阅者,一般是rviz
if (landmark_poses_list_publisher_.getNumSubscribers() > 0)
{
absl::MutexLock lock(&mutex_);
landmark_poses_list_publisher_.publish(
map_builder_bridge_.GetLandmarkPosesList() );
}
}

visualization_msgs::MarkerArray MapBuilderBridge::GetLandmarkPosesList()
{
visualization_msgs::MarkerArray landmark_poses_list;
const std::map<std::string, Rigid3d> landmark_poses =
map_builder_->pose_graph()->GetLandmarkPoses();
LOG(INFO)<<" landmark pose size: "<< landmark_poses.size();

for (const auto& id_to_pose : landmark_poses) {
landmark_poses_list.markers.push_back(CreateLandmarkMarker(
GetLandmarkIndex(id_to_pose.first, &landmark_to_index_),
id_to_pose.second, node_options_.map_frame));
}
return landmark_poses_list;
}

publish_fake_random_landmarks.py

Cartographer提供这个文件发布假的landmark,不用仔细研究内容。 节点landmark_sampler,发布话题landmark,类型cartographer_ros_msgs/LandmarkList

这个节点不是一直发布landmark,有的消息是空的

1
2
3
4
5
6
7
header: 
seq: 546
stamp:
secs: 1650511353
nsecs: 89869022
frame_id: ''
landmarks: []

有内容的消息是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
header: 
seq: 129
stamp:
secs: 1650511071
nsecs: 393965959
frame_id: ''
landmarks:
-
id: "112b2"
tracking_from_landmark_transform:
position:
x: 0.329674929632
y: 0.601299422592
z: 0.0813299308386
orientation:
x: -0.881422944417
y: 0.388576528204
z: -0.260058416952
w: 0.0668692347593
translation_weight: 100000.0
rotation_weight: 100000.0
-
id: "32c2b"
tracking_from_landmark_transform:
position:
x: 0.747269396167
y: 0.256305093347
z: 0.389155962116
orientation:
x: -0.349830783746
y: -0.891493805837
z: -0.163724271997
w: -0.236752148157
translation_weight: 100000.0
rotation_weight: 100000.0
-
id: "2c33a"
tracking_from_landmark_transform:
position:
x: 0.698094719569
y: 0.886938424567
z: 0.408522616781
orientation:
x: -0.242061311724
y: 0.292763269814
z: 0.419295012755
w: 0.824553019213
translation_weight: 100000.0
rotation_weight: 100000.0
-
id: "c3b1c"
tracking_from_landmark_transform:
position:
x: 0.703663491421
y: 0.146106286628
z: 0.589856404568
orientation:
x: -0.0750314150542
y: -0.80116295871
z: 0.151662985651
w: 0.574026601412
translation_weight: 100000.0
rotation_weight: 100000.0
-
id: "c12aa"
tracking_from_landmark_transform:
position:
x: 0.409531720117
y: 0.286439128213
z: 0.686302598836
orientation:
x: -0.326127457574
y: -0.221216268782
z: -0.89427654661
w: -0.212070040376
translation_weight: 100000.0
rotation_weight: 100000.0

参考:
Understanding the Landmark Observation Concept
Cartographer源码阅读——带Landmark的demo运行和测试