1 | // pose_estimate_2d 是 scanMatch返回的 |
时间处理部分
1 | const auto wall_time = std::chrono::steady_clock::now(); |
1 | // pose_estimate_2d 是 scanMatch返回的 |
1 | const auto wall_time = std::chrono::steady_clock::now(); |
这是表示全局 SLAM 结果的坐标系。它是包含所有闭环和优化结果的固定地图坐标系。 当新的优化结果出现时,该帧与其他帧之间的转换可以跳转。
局部SLAM结果的坐标系,不包括闭环优化,对于给定某个时间点,该帧与全局地图之间的变换可能会发生变化,但是该帧和其他帧的变换不会发生变化。
每个子图都有一个单独的坐标系
传感器数据表示的坐标系,自定义,可能是imu, laser, base_footprint
重力对准坐标系,只在2D中使用,与tracking坐标系有相同位置,但是坐标系方向不一致
将数据从 tracking坐标系 或者子图坐标系 转换到 local map frame
的变换
将数据从tracking坐标系或者子图坐标系 转换到global map frame
的变换
前端的MatchingResult
包含:1
2
3
4time 此次matching的scan时间。
local_pose 此次matching的scan的位姿。
range_data_in_local 未过滤的距离数据。
insertion_result range_data_in_local插入submap返回的结果。local map frame
和 submap的坐标系不是一回事。
子图的坐标系原点来自 range_data_in_local.origin
, 也就是ActiveSubmaps2D::InsertRangeData
中的AddSubmap(range_data.origin.head<2>());
。
InsertionResult
中的constant_data
是此次range插入submap包含的主要内容,类型为TrajectoryNode::Data
的共享指针。 constant_data
其中有个成员const transform::Rigid3d local_pose
,表示submap(node)在local map frame
中的位姿,用于这两个坐标系之间的变换, 对每个子图,local_pose
在不断更新 第1个local_pose是子图坐标系的原点,也就是上面的 range_data_in_local.origin
,也是MotionFilter的判断依据,判断位姿和上一次插入scan的位姿有一定差别
MatchingResult
里也有一个local_pose
, 和 constant_data
的那个local_pose
是相同的,赋值用的都是下一帧位姿的观测值。不过在后端里用的是InsertionResult
的local_pose
,源码在PoseGraph2D::AddNode
。这个是在AddSubmap
函数中创建新的submap的时候加入的,是创建submap时的第一个ranges的原点。
constant_data
还有一个成员global_pose
,这是submap(node)在global map frame
的位姿。
MapBuilder
在创建GlobalTrajectoryBuilder
时中会实例化 PoseGraph
对象, 并作为 GlobalTrajectoryBuilder
的成员。
接着看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/* 将前端的输出结果喂给后端进行闭环检测和全局优化 */
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_);
// 重新封装前端传来的 InsertionResult,增加了一个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() ) } );
}
PoseGraph2D
类的以FromProto
结尾的函数, 全是在 MapBuilder::LoadState 里调用的,也就是纯定位的用途。
1 | NodeId PoseGraph2D::AddNode( |
后端增加一个位姿图节点需要传递匹配后的结果包括submap和点云数据,最后返回一个位姿图节点ID。其中前端处理后的点云数据,包括水平投影的点云,重力方向和在local submap的位置。
获取激光点云当前local map下的pose,转换为全局绝对pose的转移矩阵。也就是从local map坐标系转到global map坐标系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
39transform::Rigid3d PoseGraph2D::GetLocalToGlobalTransform(
const int trajectory_id) const
{
absl::MutexLock locker(&mutex_);
return ComputeLocalToGlobalTransform(
data_.global_submap_poses_2d,
trajectory_id );
}
transform::Rigid3d PoseGraph2D::ComputeLocalToGlobalTransform(
const MapById<SubmapId, optimization::SubmapSpec2D>& global_submap_poses,
const int trajectory_id) const
{
// 查找已经优化过的trajectory id中的submap的位置,如果有可返回存储的值
auto begin_it = global_submap_poses.BeginOfTrajectory(trajectory_id);
auto end_it = global_submap_poses.EndOfTrajectory(trajectory_id);
if (begin_it == end_it)
{
const auto it = data_.initial_trajectory_poses.find(trajectory_id);
// 位姿插值
if (it != data_.initial_trajectory_poses.end())
{
return GetInterpolatedGlobalTrajectoryPose(it->second.to_trajectory_id,
it->second.time) *
it->second.relative_pose;
}
// 仅是唯一个,表明为初始submap,转移矩阵为单位矩阵
else
return transform::Rigid3d::Identity();
}
const SubmapId last_optimized_submap_id = std::prev(end_it)->id;
// 如果不存在,则返回由上个优化后的 submap_id 的位置的相对位置换算出的位置
return transform::Embed3D(
global_submap_poses.at(last_optimized_submap_id).global_pose) *
data_.submap_data.at(last_optimized_submap_id)
.submap->local_pose()
.inverse();
}
既然这个函数是返回一个transform,所以是左乘变换: global = transform * local
,因此transform = global * inv(local)
。 初始global为单位矩阵,也就是产生子图前,后续通过相对位置推导每个位置
在InitializeGlobalSubmapPoses
里可以看到获得转换矩阵的逻辑:
首先从global_submap_poses_2d
中找出属于该轨迹的所有submap信息,那就找出上一个优化过的子图的全局位姿global_pose
和局部位姿local_pose
,通过这两个位姿就可以得到 local和global坐标系 的转换矩阵。
如果在global_submap_poses_2d
没有找到该轨迹的submap信息,那就说明该轨迹还没有产生submap,那就从data_.initial_trajectory_poses
中查找该轨迹的初始位姿,如果找到 了初始位姿,就根据该轨迹初始位姿的时间去节点列表中找改时间点最近的节点,根据该节点的global_pose
找到 local 和 global 的转换信息。
initial_trajectory_poses
来自SetInitialTrajectoryPose
,根源在MapBuilder::AddTrajectoryBuilder
的trajectory_options.has_initial_trajectory_pose()
,但是没找到函数has_initial_trajectory_pose()
。怀疑这是纯定位的部分
如果initial_trajectory_poses
中也没有找到该轨迹的初始位姿,就直接返回单位转换矩阵,认为二者重合。
Rigid3
类型在rigid_transform.h
,很有学习价值1
2
3
4
5Vector translation_;
Quaternion rotation_;
// using Vector = Eigen::Matrix<FloatType, 3, 1>;
// using Quaternion = Eigen::Quaternion<FloatType>;
// using AngleAxis = Eigen::AngleAxis<FloatType>;Rigid3d
类型实质是包含了一个3行1列的Eigen矩阵,一个Eigen::Quaternion
,可以直接用 cout
值得注意的运算符:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 参见 SLAM 十四讲
Rigid3 inverse() const
{
const Quaternion rotation = rotation_.conjugate();
const Vector translation = -(rotation * translation_);
return Rigid3(translation, rotation);
}
template <typename FloatType>
Rigid3<FloatType> operator*(const Rigid3<FloatType>& lhs,
const Rigid3<FloatType>& rhs) {
return Rigid3<FloatType>(
lhs.rotation() * rhs.translation() + lhs.translation(),
(lhs.rotation() * rhs.rotation()).normalized());
}
struct NodeId
,成员只有int trajectory_id;
和 int node_index;
,后者是从0开始的
struct SubmapId
,成员只有int trajectory_id;
和 int submap_index;
,后者是从0开始的
class MapById
是对std::map的一个封装,其实是模板<typename IdType, typename DataType>
,前者可以是NodeId
或者 SubmapId
// Submaps get assigned an ID and state as soon as they are seen, even
// before they take part in the background computations.1
2
3
4
5
6
7
8
9
10
11
12
13
14struct NodeSpec2D
{
common::Time time;
transform::Rigid2d local_pose_2d;
transform::Rigid2d global_pose_2d;
Eigen::Quaterniond gravity_alignment;
};
struct SubmapSpec2D
{
transform::Rigid2d global_pose;
};
// optimization_problem_->submap_data() 就是 SubmapSpec2D
MapById<SubmapId, optimization::SubmapSpec2D> global_submap_poses_2d;
data_
是类PoseGraph2D
中的成员变量,类型 PoseGraphData
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
37struct PoseGraphData
{
/* struct InternalSubmapData
{
std::shared_ptr<const Submap> submap;
SubmapState state = SubmapState::kNoConstraintSearch;
// IDs of the nodes that were inserted into this map together with
// constraints for them. They are not to be matched again when this submap
// becomes 'kFinished'.
std::set<NodeId> node_ids;
}; */
// Submaps get assigned an ID and state as soon as they are seen, even
// before they take part in the background computations.
MapById<SubmapId, InternalSubmapData> submap_data;
// Global submap poses currently used for displaying data.
MapById<SubmapId, optimization::SubmapSpec2D> global_submap_poses_2d;
MapById<SubmapId, optimization::SubmapSpec3D> global_submap_poses_3d;
// Data that are currently being shown.
MapById<NodeId, TrajectoryNode> trajectory_nodes;
// Global landmark poses with all observations.
std::map<std::string /* landmark ID */, PoseGraphInterface::LandmarkNode>
landmark_nodes;
// How our various trajectories are related.
TrajectoryConnectivityState trajectory_connectivity_state;
int num_trajectory_nodes = 0;
std::map<int, InternalTrajectoryState> trajectories_state;
// Set of all initial trajectory poses.
std::map<int, PoseGraph::InitialTrajectoryPose> initial_trajectory_poses;
std::vector<PoseGraphInterface::Constraint> constraints;
};
1 | struct InternalSubmapData |
1 | struct Constraint { |
1 | class OptimizationProblem2D |
1 | // 结果、时间、函数 |
ConstraintBuilder2D::Result
其实是 vector<Constraint>
1 | std::deque<TimedPose> timed_pose_queue_; |
cartographer里凡是变量名包含queue的,类型都是deque |
local坐标系下的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// Rays begin at 'origin'. 'returns' are the points where obstructions were
// detected. 'misses' are points in the direction of rays for which no return
// was detected, and were inserted at a configured distance
// It is assumed that between the 'origin' and 'misses' is free space
struct RangeData {
Eigen::Vector3f origin;
PointCloud returns;
PointCloud misses;
};
using PointCloud = std::vector<RangefinderPoint>;
// Stores 3D position of a point observed by a rangefinder sensor.
struct RangefinderPoint
{
Eigen::Vector3f position;
};
1 | struct TimedPointCloudData { |
1 | struct TimedPointCloudOriginData { |
1 | // Like 'RangeData', but with 'TimedPointClouds'. |
1 | struct SubmapPose |
LocalTrajectoryBuilder2D
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
43struct MatchingResult
{
common::Time time; // 扫描匹配发生的时间
// tracking 坐标系下的位姿(Local SLAM坐标系下的位姿), 需要乘以
// tracking frame in map的转换(Local SLAM坐标系到Global SLAM坐标系的转换)才能转到全局坐标系
transform::Rigid3d local_pose;
// tracking 坐标系下的点云(即LocalSLAM坐标系下的点云)
sensor::RangeData range_data_in_local;
// 'nullptr' if dropped by the motion filter.
std::unique_ptr<const InsertionResult> insertion_result;
};
struct InsertionResult
{
// Node数据 插入的节点数据,TrajectoryNode::Data包含了处理之后的点云数据
std::shared_ptr<const TrajectoryNode::Data> constant_data;
//该Node数据插入的两个submap
std::vector<std::shared_ptr<const Submap2D>> insertion_submaps;
};
struct TrajectoryNode
{
struct Data
{
common::Time time;
// Transform to approximately gravity align the tracking frame as
// determined by local SLAM.
Eigen::Quaterniond gravity_alignment;
// Used for loop closure in 2D: voxel filtered returns in the
// 'gravity_alignment' frame.
sensor::PointCloud filtered_gravity_aligned_point_cloud;
// 省略 3D.
// The node pose in the local SLAM frame.
transform::Rigid3d local_pose;
};
common::Time time() const { return constant_data->time; }
// This must be a shared_ptr. If the data is used for visualization while the
// node is being trimmed, it must survive until all use finishes.
std::shared_ptr<const Data> constant_data;
// The node pose in the global SLAM frame.
transform::Rigid3d global_pose;
};MatchingResult
里有一个local_pose
,InsertionResult
的constant_data
成员里也有一个local_pose
, 这两个是相同的,赋值用的都是下一帧位姿的观测值。不过在后端里用的是InsertionResult
的local_pose
,源码在PoseGraph2D::AddNode
1 | // An individual submap, which has a 'local_pose' in the local map frame, |
1 | // 对点云中每一个点做变换, 左乘 transform |
1 | RangeData TransformRangeData(const RangeData& range_data, |
enum class TrajectoryState { ACTIVE, FINISHED, FROZEN, DELETED };
激光雷达点云数据均为作为主要输入,使用时无需考虑具体几个传感器和类型,可认为是一个雷达产生的点云数据。但实际cartographer通过RangeDataCollator
类将多种传感器进行了融合,并进行了时间同步,最后形成对应的时间戳,pose和点云集合。在真正使用时通过畸变校准,并构建scan match和插入submap的传感器数据rangedata。
有多个雷达的点云信息,它们的各个点可能时间会重合,因此需要将所有雷达的点云信息进行时间的整理,保证所有点的时间是单调的。多个雷达数据合并到一起,它们的原点可能不一样,因此要保存各自的原点。
头文件部分:1
2
3
4
5
6
7
8
9
10
11
12 // 插入集合
sensor::TimedPointCloudOriginData AddRangeData(
const std::string& sensor_id,
const sensor::TimedPointCloudData& timed_point_cloud_data);
private:
// 融合
sensor::TimedPointCloudOriginData CropAndMerge();
// 期望处理传感器类型清单
const std::set<std::string> expected_sensor_ids_;
// 同步和融合后集合,每种传感器至多一帧点云数据
std::map<std::string, sensor::TimedPointCloudData> id_to_pending_data_;
1 | sensor::TimedPointCloudOriginData RangeDataCollator::AddRangeData( |
进一步处理在CropAndMerge
,这个函数十分复杂。 简单理解如果有一个传感器频率较高,已经来过一帧并进行了缓存,另外一个未来,
这个传感器又来一帧,则先进行截取合并送出结果(实际上就是上帧缓存的点云直接发出),
然后将新来的一帧替换换来的buffer。
在把一帧的scan插入到子图之前,scan 相对子图的位姿是由 ceres scan matcher 优化获得的。由于这是一个实时的局部优化,需要一个好的初始位姿估计。前面的real time CSM
就是把位姿估计器传来的预测值更新为一个好的初值,如果没有real time CSM
,就还用位姿估计器传来的预测值作初值。Ceres Scan Matcher
以初值作为先验,并找到最佳的点,该点就是通过scan match获得的在子地图中的位置,实现方式是 interpolating the submap and sub-pixel aligning the scan
. 前端的两个scan matcher其实都是 scan to map 问题,让当前观测和已知环境最匹配
scan matcher 负责在地图中找到一个scan pose
,使得在该位姿下所有 hit 点在 local map
中的占用概率之和最大,于是转化为一个最小二乘问题:
式中的 就是我们需要求的位姿,它将scan坐标系中的点 (hk)全部转化到 Submap坐标系下, 让点 (hk)最大概率地匹配到Submap。 由于栅格中原本存储的就是空闲的概率,其实从栅格查出来的就是 。 相对地,real time scan match
是占据概率之和最大,其实二者本质差不多,只是CSM换成最小二乘形式了,为了更精确。
在代码里实现的时候,又加上了旋转和平移的残差,防止点云匹配的结果和初值差太多。其实只要初值够好,这两个残差可以去掉。
这种方式的速度很快,计算复杂度高,鲁棒性好,但不能修正比子图分辨率大很多的误差。如果传感器和时间戳是合理的,只使用Ceres Scan Matcher通常是最好的选择。
Msmooth
是一个将local子图中的各点概率进行平滑处理的函数,能够提供比栅格分辨率更好的精度。实质是双三次插值算法,该函数的输出结果为(0, 1)
以内的数,实际返回了Submap中的占用概率,如果趋近1,那是很好了。
提高Scan matcher的频率可以获得更好优化结果,但计算量加大;或者用IMU来估计 scan match 中的旋转角度
ceres匹配对初值要求相当高,匹配后的结果会考虑其与初始位置偏差进行权重化,说明 cartographer认为其匹配后的值应该与初值相差不大。 一旦初值离真实值过远,很容易陷入局部最优解,导致优化失败。这也是为什么ceres对线速度与角速度有限制。
1 | auto pose_observation = absl::make_unique<transform::Rigid2d>(); |
CeresScanMatcher2D
类主要函数只有Match
:根据初值,将点云hit点和栅格对齐,返回观测值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/*
input:
1. 上一个 scan 的位姿 target_translation
2. 经过 RealTimeCorrelativeScanMatcher2D 处理过的
当前的 scan 的位姿的初始估计 initial_pose_estimate
3. 当前 scan 点云(2D) point_cloud
4. local map 概率分布栅格图 grid
output: 更新的位姿估计 pose_estimate
*/
void CeresScanMatcher2D::Match(
const Eigen::Vector2d& target_translation,
const transform::Rigid2d& initial_pose_estimate,
const sensor::PointCloud& point_cloud,
const Grid2D& grid,
transform::Rigid2d* const pose_estimate,
ceres::Solver::Summary* const summary) const
{
// 优化的变量(3维)
double ceres_pose_estimate[3] = {initial_pose_estimate.translation().x(),
initial_pose_estimate.translation().y(),
initial_pose_estimate.rotation().angle() };
ceres::Problem problem;
CHECK_GT(options_.occupied_space_weight(), 0.);
// 点云匹配残差块
problem.AddResidualBlock(
CreateOccupiedSpaceCostFunction2D(
options_.occupied_space_weight() /
std::sqrt(static_cast<double>(point_cloud.size())),
point_cloud, grid ),
nullptr /* loss function */, ceres_pose_estimate );
// 省略 GridType::TSDF
CHECK_GT(options_.translation_weight(), 0.);
// ceres 的套路都在函数里定义,很简单 AutoDiff
problem.AddResidualBlock(
TranslationDeltaCostFunctor2D::CreateAutoDiffCostFunction(
options_.translation_weight(), target_translation),
nullptr, ceres_pose_estimate);
CHECK_GT(options_.rotation_weight(), 0.);
// ceres 的套路都在函数里定义,很简单
problem.AddResidualBlock(
RotationDeltaCostFunctor2D::CreateAutoDiffCostFunction(
options_.rotation_weight(), ceres_pose_estimate[2]),
nullptr, ceres_pose_estimate);
// ceres_solver_options_.linear_solver_type = ceres::DENSE_QR;
ceres::Solve(ceres_solver_options_, &problem, summary);
*pose_estimate = transform::Rigid2d(
{ceres_pose_estimate[0], ceres_pose_estimate[1]}, ceres_pose_estimate[2]);
}ceres scan matcher
将上面的最小二乘问题分解成了三个代价函数:点云与栅格的匹配残差、平移的残差、角度的残差。其中,点云匹配的权重总和为1,平移的权重和旋转的权重都是手动给的。
三个对应的核函数都是 nullptr
, ceres的linear_solver_type
是DENSE_QR
两个Cost Function的实现,就是保证平移和旋转最小的代价函数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// Computes the cost of translating 'pose' to 'target_translation'.
// Cost increases with the solution's distance from 'target_translation'.
class TranslationDeltaCostFunctor2D {
public:
static ceres::CostFunction* CreateAutoDiffCostFunction(
const double scaling_factor, const Eigen::Vector2d& target_translation)
{
return new ceres::AutoDiffCostFunction<TranslationDeltaCostFunctor2D,
2 /* residuals */,
3 /* pose variables */>(
new TranslationDeltaCostFunctor2D(scaling_factor, target_translation));
}
template <typename T>
bool operator()(const T* const pose, T* residual) const {
residual[0] = scaling_factor_ * (pose[0] - x_);
residual[1] = scaling_factor_ * (pose[1] - y_);
return true;
}
private:
// Constructs a new TranslationDeltaCostFunctor2D from the given
// 'target_translation' (x, y).
explicit TranslationDeltaCostFunctor2D(
const double scaling_factor, const Eigen::Vector2d& target_translation)
: scaling_factor_(scaling_factor),
x_(target_translation.x()),
y_(target_translation.y()) {}
// 省略: 禁止拷贝构造函数 和 赋值运算符
const double scaling_factor_;
const double x_;
const double y_;
};
这个代价函数比较简单,都是套路,RotationDeltaCostFunctor2D
也是同理
平移量的残差计算,当前位姿逼近目标位姿 (位姿估计器输出的预测值xy)。目标函数residual[0] = scaling_factor_ * (pose[0] - x_); residual[1] = scaling_factor_ * (pose[1] - y_);
逼近目标角度,目标函数scaling_factor_ * (pose[2] - angle_)
这里是逼近之前的位姿,所以要求先验位姿比较准确,经试验发现ceres输出的位姿和预测值相差极小,疑似存在问题,可以将这两个ceres优化去掉,但地图匹配必须保留
观测值 - 预测值1
2
3
4
5
6
7
8
9
10
11
12
13if (pose_observation)
{
kCeresScanMatcherCostMetric->Observe(summary.final_cost);
const double residual_distance =
(pose_observation->translation() - pose_prediction.translation())
.norm();
kScanMatcherResidualDistanceMetric->Observe(residual_distance);
const double residual_angle =
std::abs(pose_observation->rotation().angle() -
pose_prediction.rotation().angle());
kScanMatcherResidualAngleMetric->Observe(residual_angle);
}
现在进入AddAccumulatedRangeData
函数,返回类型为 std::unique_ptr<LocalTrajectoryBuilder2D::MatchingResult>
所用参数为
const common::Time time
当前同步时间const sensor::RangeData& gravity_aligned_range_data
经过重力修正后的点云returns和missconst transform::Rigid3d& gravity_alignment
, 重力向量const absl::optional<common::Duration>& sensor_duration
先看开头,我交换了部分代码顺序,并不影响。这里实际是获得scanMatch
的两个参数1
2
3
4
5
6
7
8
9
10
11if (gravity_aligned_range_data.returns.empty())
{
LOG(WARNING) << "Dropped empty horizontal range data.";
return nullptr;
}
const sensor::PointCloud& filtered_gravity_aligned_point_cloud =
sensor::AdaptiveVoxelFilter(options_.adaptive_voxel_filter_options())
.Filter(gravity_aligned_range_data.returns);
if (filtered_gravity_aligned_point_cloud.empty())
return nullptr;
这一段就是流程图的Adaptive Voxel Filter环节,解释参考(九) Voxel Filter和Adaptive Voxel Filter
然后是估计位姿部分1
2
3
4
5const transform::Rigid3d non_gravity_aligned_pose_prediction =
extrapolator_->ExtrapolatePose(time);
// 经过重力方向计算投影后的2d位置
const transform::Rigid2d pose_prediction = transform::Project2D(
non_gravity_aligned_pose_prediction * gravity_alignment.inverse() );
返回的pose_prediction
和上面的filtered_gravity_aligned_point_cloud
一起进入 Scan Mathching 环节,这个在流程图上很明显。
表面上只是重力对齐以后再处理,其实这里面还为后续的匹配与查找减小了查找范围,因为* gravity_alignment.inverse()
这一乘使得与上一帧数据的角度变化缩小了(基本是同一角度),所以 为后面的ScanMatch减小了角度的查找范围。
在后面再把角度乘回来:1
2
3
4
5pose_estimate_2d =
ScanMatch(time, pose_prediction, filtered_gravity_aligned_point_cloud);
transform::Rigid3d pose_estimate =
transform::Embed3D(*pose_estimate_2d) * gravity_alignment;
这样就实现了角度的估计,gravity_alignment上下是同一数值,只是为了减少计算量,点云数据也按这个gravity_alignment做了处理
1 | // local map frame <--- gravity-aligned frame |
这里的ScanMatch
包含了real time correlative scan matcher 和 ceres scan match两部分,前者为后者提供好的初值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// 输出是对该帧传感器数据的最优pose
std::unique_ptr<transform::Rigid2d> LocalTrajectoryBuilder2D::ScanMatch(
const common::Time time, const transform::Rigid2d& pose_prediction,
const sensor::PointCloud& filtered_gravity_aligned_point_cloud)
{
// 如果还没有子图,返回 PoseExtrapolator 的预测值
if (active_submaps_.submaps().empty() )
return absl::make_unique<transform::Rigid2d>(pose_prediction);
// 永远是头部的子图做匹配 SCAN-to-MAP
std::shared_ptr<const Submap2D> matching_submap =
active_submaps_.submaps().front();
transform::Rigid2d initial_ceres_pose = pose_prediction;
// 函数最终更新 initial_ceres_pose,返回匹配的最高分数
if (options_.use_online_correlative_scan_matching())
{
const double score = real_time_correlative_scan_matcher_.Match(
pose_prediction, filtered_gravity_aligned_point_cloud,
// Grid2D类型
*matching_submap->grid(), &initial_ceres_pose );
kRealTimeCorrelativeScanMatcherScoreMetric->Observe(score);
}
// ceres scan matcher 部分
}
这里借用了karto的做法,即本质是暴力匹配,而hector是用了高斯牛顿法和多分辨率地图。但是karto还是用了多分辨率地图,而cartographer这里没有。
它使用类似于在回环检测中激光与子地图匹配的方式,然后最好的匹配用作Ceres Scan Matcher的先验。这个scan matcher非常消耗资源,但它在特征丰富的环境中是很鲁棒。
我们要找到机器人位姿的后验概率分布,使用贝叶斯公式得到
运动模型是已知的多元高斯分布, 算法只研究观测模型。观测模型难以计算,一般具有多极值。 雷达观测数据 里包含了很多激光点数据,假设他们是完全独立的
将一帧观测数据的概率拆分成了当前帧的每一个点的概率,在计算这个概率之前,我们需要对数据帧以及地图进行栅格化。
位姿估计器传来的预测值不适合做初值,真正初值是在它附近。
实时相关性scan match 以预测值为中心,在搜索窗口内枚举出所有候选位姿,并生成每个位姿对应的点云,也就是说按照 把当前观测 投影到栅格地图 m (t 时刻的激光数据与 t-1 时刻的地图匹配, scan to map),然后分别计算每个点云中hit
点在栅格地图的CellIndex
,并计算所有hit点的占据概率之和,概率最大值对应的位姿就是我们需要的。代表着在这个位姿下,激光点得映射到占据栅格中的可能性最大,当前观测与已知环境最为一致,前后帧匹配成功。 scan match非常重要,如果结果位姿很差,建图就会失败,后端怎么调整也没用
搜索的精度是栅格地图的分辨率
暴力搜索
我们要计算 ,这就需要一个三层for循环 计算每一个位姿,还有第四个循环,也就是遍历点云中每个点。 计算量巨大,因为每一个位姿都要重新投影,需要计算sin和cos函数
计算2D切片
把三层循环交换顺序,最外层遍历 ,这样(x, y)的循环只有加法。
多分辨率搜索
首先选择一个相对低的分辨率对 map 和 scan 进行栅格化,每个栅格上保留高分辨率情况下的该区域的最高概率,这样在匹配时就不会错过正确的位姿估计
低分辨率找到最佳匹配后再在该位置进行高分辨率的栅格化,并设定相对较小的 search window 进行计算。
1 | // 更新 pose_estimate, 返回最高的分数 |
num_scans
段点云CellIndex
并保存25*num_scans
CellIndex
获取占据概率,最终对分数进行加权处理候选者是针对scan_index
(0~num_scans
)所做的x y方向的offset,生成所有候选者的过程其实就是num_scans
和x y三个循环
Match 函数的计算量很大 。 试验发现最终的分数,大部分在0.6~0.9
根据lua参数,确定后面的切片步长angular_perturbation_step_size
,切片数num_scans
,搜索区域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
41SearchParameters::SearchParameters(
const double linear_search_window,
const double angular_search_window,
const sensor::PointCloud& point_cloud,
const double resolution)
: resolution(resolution)
{
// We set this value to something on the order of
// resolution to make sure that the std::acos() below is defined.
float max_scan_range = 3.f * resolution;
for (const sensor::RangefinderPoint& point : point_cloud) {
const float range = point.position.head<2>().norm();
max_scan_range = std::max(range, max_scan_range);
}
const double kSafetyMargin = 1. - 1e-3;
angular_perturbation_step_size =
kSafetyMargin * std::acos(1. - common::Pow2(resolution) /
(2. * common::Pow2(max_scan_range)));
// 这两者的计算不同
num_angular_perturbations =
std::ceil(angular_search_window / angular_perturbation_step_size);
const int num_linear_perturbations =
std::ceil(linear_search_window / resolution);
// 切片数目,由于resolution不修改,实际取决于 max_scan_range 和 angular_search_window
num_scans = 2 * num_angular_perturbations + 1;
/* Linear search window in pixel offsets; bounds are inclusive.
struct LinearBounds {
int min_x;
int max_x;
int min_y;
int max_y;
};
std::vector<LinearBounds> linear_bounds; Per rotated scans */
linear_bounds.reserve(num_scans);
// 对同一帧scan, linear_bounds的元素都相同,不同帧就不同了
for (int i = 0; i != num_scans; ++i) {
linear_bounds.push_back(
LinearBounds{-num_linear_perturbations, num_linear_perturbations,
-num_linear_perturbations, num_linear_perturbations} );
}
}
生成序列化旋转的多段点云1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21std::vector<sensor::PointCloud> GenerateRotatedScans(
const sensor::PointCloud& point_cloud,
const SearchParameters& search_parameters)
{
std::vector<sensor::PointCloud> rotated_scans;
rotated_scans.reserve(search_parameters.num_scans);
// 旋转角度步长
double delta_theta = -search_parameters.num_angular_perturbations *
search_parameters.angular_perturbation_step_size;
// 个数 num_scans
for (int scan_index = 0; scan_index < search_parameters.num_scans;
++scan_index,
delta_theta += search_parameters.angular_perturbation_step_size)
{
// 左乘旋转向量,旋转轴为z轴
rotated_scans.push_back(sensor::TransformPointCloud(
point_cloud, transform::Rigid3f::Rotation(Eigen::AngleAxisf(
delta_theta, Eigen::Vector3f::UnitZ()) ) ) );
}
return rotated_scans;
}
1 | void ScoreCandidates( |
在TEB中的footprint模型 中提到calculateDistance
函数计算机器人到障碍物的 Euclidean 距离,用于图优化环节。
以最简单的Point类型为例1
2
3
4
5virtual double calculateDistance(const PoseSE2& current_pose,
const Obstacle* obstacle) const
{
return obstacle->getMinimumDistance(current_pose.position());
}Obstacle
是个抽象类,它的派生类有1
2
3
4
5PointObstacle
CircularObstacle
PillObstacle
LineObstacle
PolygonObstacle
显然不同的障碍类型对函数有不同实现,这里又涉及到costmap_converter
和Homotopy
,这就更复杂了
createGraph() —— PointObstacle::checkLineIntersection —— PointObstacle::checkCollision
TEB算法中的footprint模型是单独构建的,而没有直接调用通用代价地图中的 footprint,二者footprint模型不同,但在TEB中二者都有使用。
TEB的footprint用于图优化,costmap的 footprint用于 `feasibility check`环节LayeredCostmap
类的成员函数: double getInscribedRadius() { return inscribed_radius_; }
对于polygon轮廓,两种footprint模型的内接半径相同, TEB的模型没有返回外接半径的函数
在TebLocalPlannerROS::initialize
中定义: RobotFootprintModelPtr robot_model = getRobotFootprintFromParamServer(nh);
,然后进行模型验证validateFootprints
在planner初始化时,也就是在TebOptimalPlanner::initialize
中赋值给robot_model_
.
RobotFootprintModelPtr 其实就是 BaseRobotFootprintModel类型的 boost::shared_ptr |
很显然BaseRobotFootprintModel
是个抽象类,定义了机器人footprint/contour models的接口,根据yaml设置的类型而定义相应的类,这个类只用于optimization,代价地图的footprint只用于检查路径的可行性
Point 和 circular模型的footprints要求算力最少。
two-circles
模型要求的是两次 distance calculations
line
模型增加了一些 simple case checking, 但是算力在大多数情况仍要求不高。
polygon
模型对每一个edge都要求 distance check,所以计算时间主要取决于 edges和vertices 的数量
也就是说这几种模型的算力要求依次增大。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
60RobotFootprintModelPtr TebLocalPlannerROS::getRobotFootprintFromParamServer(const ros::NodeHandle& nh)
{
std::string model_name;
if (!nh.getParam("footprint_model/type", model_name))
{
// 如果未指定类型,则使用点类型
return boost::make_shared<PointRobotFootprint>();
}
// point
if (model_name.compare("point") == 0)
{
ROS_INFO("Footprint model 'point' loaded for trajectory optimization.");
return boost::make_shared<PointRobotFootprint>();
}
// circular 省略
if (model_name.compare("circular") == 0)
// line 省略
if (model_name.compare("line") == 0)
// two circles 省略
if (model_name.compare("two_circles") == 0)
// polygon 只对 Point2dContainer vertices_; 赋值
if (model_name.compare("polygon") == 0)
{
// check parameters
XmlRpc::XmlRpcValue footprint_xmlrpc;
if (!nh.getParam("footprint_model/vertices", footprint_xmlrpc) )
{
ROS_ERROR_STREAM("Footprint model 'polygon' cannot be loaded for
trajectory optimization, since param '" << nh.getNamespace() <<
"/footprint_model/vertices' does not exist. Using point-model instead");
return boost::make_shared<PointRobotFootprint>();
}
// get vertices
if (footprint_xmlrpc.getType() == XmlRpc::XmlRpcValue::TypeArray)
{
try
{
Point2dContainer polygon = makeFootprintFromXMLRPC(footprint_xmlrpc,
"/footprint_model/vertices");
ROS_INFO_STREAM("Footprint model 'polygon' loaded for trajectory optimization.");
return boost::make_shared<PolygonRobotFootprint>(polygon);
}
catch(const std::exception& ex)
{
ROS_ERROR_STREAM("Footprint model 'polygon' cannot be loaded for trajectory optimization: "
<< ex.what() << ". Using point-model instead.");
return boost::make_shared<PointRobotFootprint>();
}
}
else
{
ROS_ERROR_STREAM("Footprint model 'polygon' cannot be loaded for trajectory optimization,
since param '" << nh.getNamespace() << "/footprint_model/vertices' does not
define an array of coordinates. Using point-model instead.");
return boost::make_shared<PointRobotFootprint>();
}
}
return boost::make_shared<PointRobotFootprint>();
}
1 | // TebLocalPlannerROS::initialize中的调用 |
函数就一句ROS_WARN_COND
,要求 TEB优化所用的机器人内接半径 + 参数 min_obstacle_dist > costmap中的内接半径(包括footprint_padding) ,否则Infeasible optimziation results会频繁出现
这里就是设置为点类型的缺陷之处,第1参数个会是0,但是如果min_obstacle_dist
超过costmap中的内接半径,会无法走窄通道。
BaseRobotFootprintModel
的派生类实现了calculateDistance
函数,计算机器人到障碍物的 Euclidean距离,用于图优化环节。以最简单的Point类型为例1
2
3
4
5virtual double calculateDistance(const PoseSE2& current_pose,
const Obstacle* obstacle) const
{
return obstacle->getMinimumDistance(current_pose.position());
}
至于细节,需要单独一篇文章分析
变量robot_model_
在之后用于图优化时的一系列函数:AddEdgesObstacles
AddEdgesObstaclesLegacy
AddEdgesDynamicObstacles
AddEdgesVelocityObstacleRatio
,也就是所有和障碍相关的约束
以及可视化中的publishInfeasibleRobotPose
配置使用的是costmap_converter::CostmapToLinesDBSRANSAC
,类继承关系:
1 | class CostmapToLinesDBSRANSAC : public CostmapToPolygonsDBSMCCH : public BaseCostmapToPolygons |
这个机制太复杂了,还没有搞清楚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
34void workerCallback(const ros::TimerEvent&)
{
updateCostmap2D();
compute();
}
void CostmapToPolygonsDBSMCCH::updateCostmap2D()
{
occupied_cells_.clear();
if (!costmap_->getMutex())
{
ROS_ERROR("Cannot update costmap since the mutex pointer is null");
return;
}
int idx = 0;
costmap_2d::Costmap2D::mutex_t::scoped_lock lock(*costmap_->getMutex());
// get indices of obstacle cells
for(std::size_t i = 0; i < costmap_->getSizeInCellsX(); i++)
{
for(std::size_t j = 0; j < costmap_->getSizeInCellsY(); j++)
{
int value = costmap_->getCost(i,j);
if(value >= costmap_2d::LETHAL_OBSTACLE)
{
double x, y;
costmap_->mapToWorld((unsigned int)i, (unsigned int)j, x, y);
occupied_cells_.push_back( KeyPoint( x, y ) );
}
++idx;
}
}
}