1 | problem->AddResidualBlock( |
1 | static ceres::CostFunction* CreateAutoDiffCostFunction( |
1 | template <typename T> |
1 | template <typename T> |
relative_pose
是 landmark_to_tracking_transform
,landmark和tracking坐标系的相对位姿,在SensorBridge::HandleLandmarkMessage
中获得
landmark作为独立变量进入Pose Graph的稀疏优化,它增加的优化维度是 landmarks个数 * 每个landmark的参数个数
,不要使用太多landmark
ScaleError
一看就是残差乘以权重1
2
3
4
5
6
7
8
9
10
11template <typename T>
std::array<T, 3> ScaleError( const std::array<T, 3>& error,
double translation_weight,
double rotation_weight )
{
return {{
error[0] * translation_weight,
error[1] * translation_weight,
error[2] * rotation_weight
}};
}
加入landmark后,Pose Graph有了不同的node和edge,也就是landmark也作为node,landmark-pose之间的 edge是新的一种edge,即在pose对landmark的观测
1 | struct LandmarkNode { |
landmark是应用于后端,不是实时的,应当调大权重让carto更相信它的结果
添加数据部分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
27void PoseGraph2D::AddLandmarkData(int trajectory_id,
const sensor::LandmarkData& landmark_data)
{
AddWorkItem([=]() LOCKS_EXCLUDED(mutex_) {
absl::MutexLock locker(&mutex_);
if (CanAddWorkItemModifying(trajectory_id)) {
for (const auto& observation : landmark_data.landmark_observations)
{
// initial landmark_nodes
// const transform::Rigid3d global_pose =
// observation.landmark_to_map_transform;
data_.landmark_nodes的类型是 std::map<std::string /* landmark ID */, PoseGraphInterface::LandmarkNode>
data_.landmark_nodes[observation.id].landmark_observations.emplace_back(
PoseGraphInterface::LandmarkNode::LandmarkObservation{
trajectory_id, landmark_data.time,
observation.landmark_to_tracking_transform,
observation.translation_weight, observation.rotation_weight} );
// data_.landmark_nodes[observation.id].global_landmark_pose = global_pose;
}
}
// optimization_problem_->new_landmark_add_ = true;
return WorkItem::Result::kDoNotRunOptimization;
});
}
这里就是把观测到的landmark信息都保存到data_.landmark_nodes
,最后返回的标识也是不优化
后端优化的流程和其他传感器数据一样:PoseGraph2D::HandleWorkQueue
—— PoseGraph2D::RunOptimization
—— OptimizationProblem2D::Solve(data_.constraints, GetTrajectoryStates(), data_.landmark_nodes)
—— AddLandmarkCostFunctions(landmark_nodes, node_data_, &C_nodes, &C_landmarks, &problem, options_.huber_scale() )
OptimizationProblem2D::Solve
部分: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 std::set<int> frozen_trajectories;
for (const auto& it : trajectories_state)
{
if (it.second == PoseGraphInterface::TrajectoryState::FROZEN)
{
frozen_trajectories.insert(it.first);
}
}
ceres::Problem::Options problem_options;
ceres::Problem problem(problem_options);
// Set the starting point. TODO(hrapp): Move ceres data into SubmapSpec.
// ceres需要的是double指针,std::array 能转成原始指针的形式
MapById<SubmapId, std::array<double, 3>> C_submaps;
MapById<NodeId, std::array<double, 3>> C_nodes;
std::map<std::string, CeresPose> C_landmarks;
bool first_submap = true;
// 将需要优化的 子图位姿 设置为优化参数
for (const auto& submap_id_data : submap_data_) {
// submap_id的轨迹是否是冻结的
const bool frozen =
frozen_trajectories.count(submap_id_data.id.trajectory_id) != 0;
// 将子图的global_pose放入 C_submaps
C_submaps.Insert(submap_id_data.id,
FromPose(submap_id_data.data.global_pose));
// c++11中,std::array::data()返回指向数组对象第一个元素的指针
problem.AddParameterBlock(C_submaps.at(submap_id_data.id).data(), 3);
// 第一个子图或冻结,不优化子图位姿。 也就是不优化初值
if (first_submap || frozen) {
first_submap = false;
// Fix the pose of the first submap or all submaps of a frozen
// trajectory.
problem.SetParameterBlockConstant(C_submaps.at(submap_id_data.id).data());
}
}
// 需要优化的 节点位姿 设置为优化参数,与上面的子图优化大致相同
for (const auto& node_id_data : node_data_)
{
const bool frozen =
frozen_trajectories.count(node_id_data.id.trajectory_id) != 0;
C_nodes.Insert(node_id_data.id, FromPose(node_id_data.data.global_pose_2d));
problem.AddParameterBlock(C_nodes.at(node_id_data.id).data(), 3);
if (frozen)
{
// 这里的第一个节点也要参与优化,跟子图的不同了
problem.SetParameterBlockConstant(C_nodes.at(node_id_data.id).data());
}
}
/* ...... */
// 第2种残差:landmark与landmark数据插值出来的节点相对位姿的差值
AddLandmarkCostFunctions(landmark_nodes, node_data_,
&C_nodes, &C_landmarks,
&problem, options_.huber_scale() );
landmark数据 与 通过2个节点位姿插值出来的相对位姿 的差值作为残差项
最外层两个循环是遍历landmark节点和遍历每个节点的观测,也就是说可能会同时看到多个landmark。直接看循环里面的内容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
29const std::string& landmark_id = landmark_node.first;
// 轨迹中第一个 node_data
const auto& begin_of_trajectory =
node_data.BeginOfTrajectory(observation.trajectory_id);
// 如果 landmark observation was made before the trajectory was created
if (observation.time < begin_of_trajectory->data.time) {
continue;
}
/* 以下语句Find the trajectory nodes before and after the landmark observation */
// 找到在landmark观测时间后的第一个节点
auto next =
node_data.lower_bound(observation.trajectory_id, observation.time);
/* The landmark observation was made, but the next trajectory node has
not been added yet. 即next已经是轨迹最后一个节点 */
if (next == node_data.EndOfTrajectory(observation.trajectory_id) ) {
continue;
}
// 如果是刚开始的node data
if (next == begin_of_trajectory) {
next = std::next(next);
}
// 这里的pre配合next是为了获取两个位置,找到landmark观测时间的前一个节点
auto prev = std::prev(next);
// 根据两个索引,获取两个节点位姿
// Add parameter blocks for the landmark ID if they were not added before
std::array<double, 3>* prev_node_pose = &C_nodes->at(prev->id);
std::array<double, 3>* next_node_pose = &C_nodes->at(next->id);
根据landmark观测的时间,找到观测前后的节点,获取位姿
1 | // 如果landmark_id 不存在于 C_landmarks, |
开始C_landmarks
当然为空,只有在优化完成后,这里才存在,即 RunOptimization
的最后会添加: data_.landmark_nodes[landmark.first].global_landmark_pose = landmark.second;
,顺着上面的调用路线就能发现
这里用了一堆数据类型转换,以及四元数、旋转向量、欧拉角之间的转换。还重新实现了slerp函数SlerpQuaternions
,因为Eigen的slerp与Ceres的不兼容。
1 | C_landmarks->emplace( |
CeresPose
的构造函数里就两行1
2
3
4problem->AddParameterBlock(data_->translation.data(), 3,
translation_parametrization.release());
problem->AddParameterBlock(data_->rotation.data(), 4,
rotation_parametrization.release());
对于平移,无需ceres::LocalParameterization
1 | problem->AddResidualBlock( |
if (!C_landmarks->count(landmark_id))
已经结束,添加残差块。
Solve
的最后1
2
3
4for (const auto& C_landmark : C_landmarks) {
// first 是 landmark_id
landmark_data_[C_landmark.first] = C_landmark.second.ToRigid();
}
这个C_landmarks
还是上面那个,ToRigid()
当然是优化后的landmark全局位姿了
在RunOptimization
的最后1
2
3
4
5
6// 遍历成员变量 landmark_data_,它在 Solve 函数的最后赋值
for (const auto& landmark : optimization_problem_->landmark_data() )
{
// first 是 landmark_id
data_.landmark_nodes[landmark.first].global_landmark_pose = landmark.second;
}optimization_problem_->landmark_data()
返回的就是上面的landmark_data_
,说白了,这里就是把优化后的landmark数据从OptimizationProblem2D
层传回PoseGraph2D
层
landmark就是添加了一个观测,让定位更鲁棒。如果观测是空的,应当发布空的 landmark messages to unblock the queue. 对于fixed frame poses (GPS)也适用。
node.cc
层1 | // landmark_poses_list 话题 |
发布话题:1
2
3
4
5
6
7
8void Node::PublishLandmarkPosesList(const ::ros::WallTimerEvent& unused_timer_event)
{
if (landmark_poses_list_publisher_.getNumSubscribers() > 0)
{
absl::MutexLock lock(&mutex_);
landmark_poses_list_publisher_.publish(map_builder_bridge_.GetLandmarkPosesList() );
}
}
处理传感器数据1
2
3
4
5
6
7
8
9
10
11void 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;
CHECK(msg->landmarks.size() != 0) << "No Landmarks !";
map_builder_bridge_.sensor_bridge(trajectory_id)->HandleLandmarkMessage(sensor_id, msg);
}
sensor_bridge.cc
层用到的类型1
2
3
4
5
6
7
8
9
10
11
12struct LandmarkData {
common::Time time;
std::vector<LandmarkObservation> landmark_observations;
};
struct LandmarkObservation
{
std::string id;
transform::Rigid3d landmark_to_tracking_transform;
double translation_weight;
double rotation_weight;
};
如果是二维码,就可以将识别到的ID传进去,landmark的观测是相对于 tracking 坐标系的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20void SensorBridge::HandleLandmarkMessage(
const std::string& sensor_id,
const cartographer_ros_msgs::LandmarkList::ConstPtr& msg)
{
auto landmark_data = ToLandmarkData(*msg);
auto tracking_from_landmark_sensor = tf_bridge_.LookupToTracking(
landmark_data.time, CheckNoLeadingSlash(msg->header.frame_id));
// 把相机对landmark的观测,转换到 tracking 坐标系
if (tracking_from_landmark_sensor != nullptr)
{
for (auto& observation : landmark_data.landmark_observations)
{
observation.landmark_to_tracking_transform =
*tracking_from_landmark_sensor *
observation.landmark_to_tracking_transform;
}
}
// 进入传感器数据融合阶段
trajectory_builder_->AddSensorData(sensor_id, landmark_data);
}
简单的类型转换而已1
2
3
4
5
6
7
8
9
10
11
12
13
14LandmarkData ToLandmarkData(const LandmarkList& landmark_list)
{
LandmarkData landmark_data;
landmark_data.time = FromRos(landmark_list.header.stamp);
for (const LandmarkEntry& entry : landmark_list.landmark)
{
landmark_data.landmark_observations.push_back(
{entry.id, ToRigid3d(entry.tracking_from_landmark_transform),
entry.translation_weight, 10, entry.type});
//LOG(INFO) << "find landmark x: " << entry.tracking_from_landmark_transform.position.x;
//LOG(INFO) << "find landmark y: " << entry.tracking_from_landmark_transform.position.y;
}
return landmark_data;
}
CollatedTrajectoryBuilder
类1
2
3
4
5
6
7
8
9
10
11
12void AddSensorData(const std::string& sensor_id,
const sensor::LandmarkData& landmark_data) override
{
if (collate_landmarks_)
{
AddData(sensor::MakeDispatchable(sensor_id, landmark_data));
return;
}
// wrapped_trajectory_builder_ 赋值在 CollatedTrajectoryBuilder 构造函数
// 应当为map_builder_->GetTrajectoryBuilder(trajectory_id)
wrapped_trajectory_builder_->AddSensorData(sensor_id, landmark_data);
}
collate_landmarks
的设置问题在CollatedTrajectoryBuilder
的构造函数里体现,注意设置为on
或off
,不是true, false1
2
3
4
5if (sensor_id.type == SensorId::SensorType::LANDMARK &&
!collate_landmarks_)
{
continue;
}
如果提供landmark的观测不低于10 Hz,那么可以设置TRAJECTORY_BUILDER.collate_landmarks = on
. Cartographer将deterministically运行(对于给定的bag, 使用offline node每次获得的结果是一样的). 如果collate_landmarks = off
, landmark observations将跳过sensor queue
(so they do not block it if they are sparse) and are injected directly into the pose graph, which is not deterministic.
但是看到另一种说法,在 #1224
更新版本,作者提出如果有landmark,collate_landmarks
就要为 false。这个再观察
接下来进入GlobalTrajectoryBuilder
1
2
3
4
5void AddSensorData(const std::string& sensor_id,
const sensor::LandmarkData& landmark_data) override
{
pose_graph_->AddLandmarkData(trajectory_id_, landmark_data);
}
这里可以看到landmark数据直接进后端,前端没有它的事了
看到网上有些人说标定结果不错,但我试了很多次,开始还能出运行结果,但是和实际差太多,后来换了个相机,几乎不能运行成功了。kalibr图案、棋盘格、apriltag都试过了,代码调试了很多地方,都不顺利,看github也有很多issue未解决,先搁置了
laser_filter
的配置filter.yaml
修改如下1
2
3
4
5
6
7
8
9
10
11
12scan_filter_chain:
- name: angle
type: laser_filters/LaserScanAngularBoundsFilter
params:
lower_angle: -0.6
upper_angle: 0.6
- name: range
type: laser_filters/LaserScanRangeFilter
params:
use_message_range_limits: false
lower_threshold: 0.5
upper_threshold: 2.2
配置文件中的tag_spacing = 0.3
并不是tag_spacing
的实际距离大小,而是比例:8.8x0.3=2.64,因此这里的tag_spacing是一个固定值为0.3,不用修改!
录制bag要包括雷达scan和图像1
rosbag record scan_filtered /camera_node/image_raw -O pinhole.bag
roslaunch lasercamcal_ros kalibra_apriltag.launch
会把检测到每一帧中的april的信息保存到apriltag_pose.txt
文件中,作者录制的数据包有1527 msgs,最终也是每一帧都检测到了,我录制的bag有时会有一些帧检测不到,似乎不是什么问题。
作者也是没有对录制的包直接做数据时间同步的,相机的帧率大概是2D激光雷达的二倍
要求是:标定板在激光正前方120°范围内,并且激光前方2m范围内只存在一个连续的直线线段,所以请在空旷的地方采集数据,不然激光数据会提取错误。需要充分旋转 roll 和 pitch。更直白一点,假设在长方形的标定板中心画了个十字线,那请绕着十字线的两个轴充分旋转,比如绕着竖轴旋转,然后还要绕着横轴旋转。在运行offline程序时,程序会将认为正确的直线会显示为红色。
激光线要能落在标定板上。不断调整标定板姿态,每换一个姿态(绕Roll, Pitch, Yaw旋转,移动位置),请保持静止 2 秒以上,采集 10 个姿态左右的数据(当然越多越好)。用 rosbag 记录所有的图像和激光数据, 标定工具会自动检测你保持静止时刻的数据,并用来标定。
1 | --------- start AutoGetLinePts --------- |
这里的1000,1414其实就是对应scan的range为nan的情况,在if(d1 < range_max && d2 < range_max)
里过滤掉了,不用管了
运行roslaunch lasercamcal_ros calibra_offline.launch
,结果OpenCV窗口的标定板对应线段不是红色,也没有results.yaml
终端出现Valid Calibra Data too little
查代码发现points = AutoGetLinePts(Points);
没有检测到直线。接着查发现问题在selectScanPoints.cpp
中的1
2
3
4
5
6
7
8
9 // 至少长于 20 cm, 标定板不能距离激光超过2m, 标定板上的激光点超过 50 个
if(dist.head(2).norm() > 0.2
&& points.at(seg.id_start).head(2).norm() < 2
&& points.at(seg.id_end).head(2).norm() < 2
&& seg.id_end-seg.id_start > 50 )
{
seg.dist = dist.head(2).norm();
segs.push_back(seg);
}
有时第一项和最后一项不符合;有时第一项符合,但中间两项又不符合;第四项大部分情况是3. 常见的四个参数如下1
2
3
4
5
6
7
8
90.100479
1.294
1.218
3
0.201422
2.357
2.513
3
看来是不符合只有一个连续直线这条要求,后来又试了一次,虽然出现了红色直线,但是错的
最后终于明白了,源码中的参数都是作者的雷达相关的,需要根据自己雷达调整。int delta = 80/0.3;
的两个参数根据雷达角分辨率调整。50降为18,这样就成功了,int skip = 3;
也可能要调整。
运行calibra_offline.launch
得到的bug1
2
3
4Load apriltag pose size: 582
terminate called after throwing an instance of 'std::out_of_range'
what(): vector::_M_range_check: __n (which is 18446744073709551615) >= this->size() (which is 57)
[lasercamcal_ros-1] process has died
有时连录几次bag,都出现这种情况,估计是某个地方在for循环里push_back
导致
计算初值阶段1
2
3
4
5
6
7
8
9~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Notice Notice Notice: system unobservable !!!!!!!
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
------- Closed-form solution Tlc: -------
1 0 0 -nan
0 1 0 -nan
0 0 1 nan
0 0 0 1
建立tf树 A—->B—->C,求 C—->A变换1
2rosrun tf static_transform_publisher 0.169, 0.035, -0.125, -1.57, 0, -1.57 A B 100
rosrun tf static_transform_publisher 0, 0.015, 0, -1.57, 0, -1.57 B C 100
平时使用的tf是tf::Transformer::lookupTransform(target_frame, source_frame)
,表示的是 source_frame ---> target_frame
的变换,获得的位姿关系是子在父坐标系中,所以是lookupTransform("A", "B")
,根据《SLAM十四讲》63页的表示方法,写成
,也就是父子顺序。 因此 C—->A变换是
CMake
关键部分:1
2
3
4
5
6
7find_package(catkin REQUIRED COMPONENTS
eigen_conversions
roscpp
tf
pcl_ros
)
find_package(PCL 1.3 REQUIRED COMPONENTS common io)
c++关键部分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
tf::TransformListener listener;
ros::Rate rate(10.0);
while (node.ok())
{
//创建一个StampedTransform对象存储变换结果数据
tf::StampedTransform ab;
tf::StampedTransform bc;
tf::StampedTransform tfAC;
try{
listener.lookupTransform("A", "B",
ros::Time(0), ab);
listener.lookupTransform("B", "C",
ros::Time(0), bc);
listener.lookupTransform("A", "C",
ros::Time(0), tfAC);
// 输出 lookupTransform 输出的 C--->A 变换
cout << "X: "<< tfAC.getOrigin().getX() << " Y: "<< tfAC.getOrigin().getY()
<<" Z: "<< tfAC.getOrigin().getZ()<< endl;
cout << "quaternion: "<< tfAC.getRotation().x() <<" " << tfAC.getRotation().y()<<
" " << tfAC.getRotation().z() << " " << tfAC.getRotation().w() << endl;
Eigen::Matrix4f AB, BC, AC;
// 使用 pcl_ros 直接得到 4x4的齐次变换矩阵
pcl_ros::transformAsMatrix(ab, AB);
pcl_ros::transformAsMatrix(bc, BC);
std::cout << "AB:" <<std::endl;
std::cout << AB <<std::endl;
std::cout << "BC:" <<std::endl;
std::cout << BC <<std::endl;
std::cout << "AC:" <<std::endl;
AC = AB * BC;
std::cout << AC <<std::endl;
cout << "-------------------------------------" << endl;
}
catch (tf::TransformException &ex) {
ROS_ERROR("%s",ex.what());
ros::Duration(1.0).sleep();
continue;
}
}
可以看到AC的右侧平移部分和tfAC.getOrigin()
部分相同
tf::lookupTransform
的源码一直追溯到tf2_ros::Buffer::lookupTransform
,源码在f2/src/buffer_core.cpp,注意其中的accum
函数和BufferCore::walkToTopParent
函数
tf_eigen.h
提供了不少很有用的函数,用于tf
和Eigen
之间的矩阵和向量互相转换。使用前1
2
3
4find_package(catkin REQUIRED COMPONENTS
eigen_conversions
tf_conversions
)
头文件#include <tf_conversions/tf_eigen.h>
1 | // Converts an Eigen Quaternion into a tf Matrix3x3. |
realsense和雷达外参的粗标定,也就是laser
坐标系和camera_color_optical_frame
之间的相对位姿,但如果realsense是低着头,竖直和横向的相对位姿的测量误差就大了。
使用realsense检测贴在墙上的二维码,获得距离,同时使用雷达检测到墙的距离,让雷达尽量正对二维码位置。
雷达到前方距离: 1.288,对应realsense的z值: 1.119,二者差 0.169
用直尺测量高度的差: 0.125
横向: realsense在雷达左侧 0.035
如果laser
为父坐标系,二者的变换矩阵为
但realsense的坐标系体系是 camera_link
—-> camera_color_frame
—-> camera_color_optical_frame
,其中1
2
3
4
5camera_link camera_color_optical_frame
- Translation: [0.000, 0.015, 0.000]
- Rotation: in Quaternion [-0.498, 0.502, -0.498, 0.502]
in RPY (radian) [-1.571, 0.008, -1.570]
in RPY (degree) [-89.992, 0.476, -89.975]
所以不能直接建立laser
坐标系和camera_color_optical_frame
的父子关系,要建立laser
和camera_link
的。
使用变换矩阵的计算,已知 , 。 计算
结果转为tf格式,应当为 <node pkg="tf" type="static_transform_publisher" name="laser_camera_broadcaster" args="0.169 0.02 -0.125 0 0 0 laser camera_link 100"/>
标准化后的向量点乘得到的值为夹角的余弦。如果得到 -1, 0, -1,便可知道两个向量方向的关系是相反, 垂直, 相同
向量叉乘后得到的向量和原先两个向量垂直,也就是法向量
向量叉乘后得到的向量的模,其值为两个向量构成的三角形的面积的二倍。
用行列式的表示方法观察,不用计算就能看出前三条。
高中学过的表示方法为 ,法向量为 。这里涉及到了归一化问题,两边可以乘以系数,还是同一个平面,也可以说法向量是不唯一的。但如果给出了一般式 ,就说法向量为
空间内一点 到平面的距离是 ,如果已经归一化,那么分母就是 1
法向量可以看做平面上两个向量的叉乘。
N
为法向量(1x3的向量),那么[N, d]
或者(N | d)
为 1x4 的向量,其中d为平面到原点的距离。
现在平面的表示形式(无论哪种)都是基于坐标系A,另有一个坐标系B,从B到A的变换矩阵是M(4x4),那么平面在坐标系B下的表示形式是什么?
向量 ,注意不是法向量,平面上一点为 ,那么 ,经过变换后,点v
变成了 ,那么有 ,也就有,因此可得 ,M矩阵的逆根据《SLAM十四讲》的公式3.13
可以直接写出,最后根据 写成一般式
感觉这样推导并不严谨,但可以这样理解。
最常见的一个例子是 链接,不再复制粘贴
还是之前的曲线拟合问题: ,如果改用解析求导,需要构建一个继承CostFunction
的类。核心函数是bool CostFunction: :Evaluate(double const *const *parameters, double *residuals, double **jacobians)
,计算残差向量和雅可比矩阵
parameters: parameters是一个大小为CostFunction::parameter_block_sizes_.size()
(输入参数块的数量和大小)的数组,parameters[i]是一个大小为parameter_block_sizes_[i]
的数组,其中包含CostFunction
所依赖的第i个参数块。parameters不会为nullptr
residuals: 是一个大小为num_residuals_
(输出残差的个数)的数组。residuals也不会为nullptr
jacobians: 是一个大小为CostFunction::parameter_block_sizes_.size()
的数组。如果jacobians
是nullptr,用户只需要计算残差
jacobians[i]: 是一个大小为 num_residuals x parameter_block_sizes_[i]
的行数组,如果jacobians[i]
不为nullptr,用户需要计算关于parameters[i]
的残差向量的雅可比矩阵,并将其存储在这个数组中,即
如果jacobians
为nullptr,通常我们只需要在Evaluate
函数中实现residuals
的计算,jacobians
后面可以通过Ceres提供的自动求导(数值求导)替代,否则,还需要在Evaluate
函数中实现jacobians
的计算
如果parameters
大小和residuals
大小在编译时是已知的,就可以使用SizeCostFunction
,该函数可以将paramters大小和residuals大小设置为模板参数,用户只需要在使用的时候给模板参数赋值就可以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// 模板参数依次为: number of residuals, first parameter的维度
class MyCostFun : public SizedCostFunction<1, 1, 1, 1>
{
public:
MyCostFun(double x, double y):
m_x(x), m_y(y){}
virtual bool Evaluate(double const* const* parameters,
double* residuals,
double** jacobians) const
{
double a = parameters[0][0];
double b = parameters[0][1];
double c = parameters[0][2];
residuals[0] = m_y - exp(a*m_x*m_x + b*m_x + c);
if (jacobians != NULL && jacobians[0] != NULL)
{
jacobians[0][0] = -exp(a*m_x*m_x + b*m_x + c)* m_x * m_x;
jacobians[0][1] = -exp(a*m_x*m_x + b*m_x + c)* m_x;
jacobians[0][2] = -exp(a*m_x*m_x + b*m_x + c);
}
return true;
}
protected:
double m_x;
double m_y;
};
拟合 时,使用SizedCostFunction<1, 2>
,只计算雅格比jacobians[0][0]
和 jacobians[0][1]
1 | int main(int argv, char** argc) |
一开始我设置代价函数的模板为SizedCostFunction<1, 3>
,结果运行报错: [problem_impl.cc:286] Check failed: num_parameter_blocks == cost_function->parameter_block_sizes().size() (3 vs. 1)
问题在于parameter_block_sizes
只有1,而我们需要3,也就是对应abc三个参数。读SizedCostFunction
源码发现,模板定义为template <int kNumResiduals, int... Ns>
,构造函数只有两行1
2set_num_residuals(kNumResiduals);
*mutable_parameter_block_sizes() = std::vector<int32_t>{Ns...};
因此从第2个模板参数开始,有几个参数就对应parameter_block_sizes
,改为SizedCostFunction<1, 1, 1, 1>
四元数表示的是一个SO3
,四元数有四维即四个自由度,但它表示的含义其实只有三个自由度,这显然是不合理的,所以产生了单位四元数这么一个东西,单位四元数指四元数的四个量的二范数是1,这其实是一个约束,约束了四元数的一个自由度,这样四元数就只剩下三个自由度了,正好符合一个SO3的维数。
这段话没有完全理解: 这里其实是一个 Manifold 变量 over parameterized的问题,即 Manifold 上变量的维度大于其自由度。这会导致 Manifold 上变量各个量之间存在约束,如果直接对这些量求导、优化,那么这就是一个有约束的优化,实现困难。为了解决这个问题,在数学上是对 Manifold 在当前变量值处形成的切空间(Tangent Space)求导,在切空间上优化,最后投影回 Manifold。
在ceres里面,如果使用的是自动求导,然后再结合爬山法,那么每步迭代中都会产生一个四维的迭代的增量delta
,这样就仅仅需要将原四元数 “加上” 这个迭代产生的delta
就能够得到新的四元数了。这里问题就来了,直接加上以后这个四元数就不再是一个单位四元数了,不符合二范数之和为1。如果每次迭代过后,都将这个四元数进行归一化处理,就会显然很麻烦。
同理,旋转矩阵也是如此。二者都是使用过参数化表示旋转的方式,它们是不支持广义的加法(一般使用符号 表示)。也就是说使用普通的加法就会打破约束:旋转矩阵加旋转矩阵得到的就不再是旋转矩阵,四元数加四元数得到的不是单位四元数。所以我在使用ceres对其进行迭代更新的时候就需要自定义其更新方式了,具体的做法是实现一个参数本地化的子类,需要继承LocalParameterization
,它是纯虚类,所以继承的时候要把所有的纯虚函数都实现。
Ceres存储四元数的顺序是(w, x, y, z)
,Eigen::Quaternion
内部内存分布为(x, y, z, w)
1 |
|
对两个Size,我的理解是:四元数本身的维度是 4,但其自由度只有 3,因此迭代量的维度也应该是 3
原型: bool Plus(const double* x, const double* delta, double* x_plus_delta)
四元数的更新方法,参数分别为优化前的四元数 x,用旋转矢量表示的增量delta
( 而且本来就是半轴角,所以不需要除以2 ),更新后的四元数 x_plus_delta
。函数首先将增量由旋转矢量转换为四元数,转换过程就是《视觉SLAM十四讲》的公式(3.19),然后用优化前的x
左乘增量,获得x_plus_delta
1 | bool QuaternionParameterization::Plus(const double* x, |
原型:bool ComputeJacobian(const double* x, double* jacobian)
四元数相对于旋转矢量的雅克比矩阵,即 x_plus_delta
( 也就是 Plus(x, delta) ) 关于 delta
(接近0)的雅克比矩阵。计算该参数加上更新量的结果对更新量的雅可比矩阵
四元数维度为 4,迭代量维度为 3,因此雅可比维度应该是 4x3。也就是
如果直接在求观测误差对四元数求雅可比,得到的结果维度和该变量的维度是一致的,也就是说 雅格比同样具有冗余的自由度 ,还是开始就提出的问题,因此还需要对其迭代量进行雅可比求解。数学表示如下
(2)式为四元数上的例子,q
为四元数,维度为 4,为作用在四元数上的更新量,维度为 3
上面两个公式的第一部分是对原始过参数化的优化变量(四元数)的导数,这个很容易求得,直接借助ceres的AutoDiffCostFunction()
计算即可,或者自己计算雅可比矩阵,实现一个costfunction。关键是第二部分。
先补充一下四元数知识,来自十四讲
继续推导如下1
2
3
4
5
6
7
8
9
10// x[0] 对应 w
bool QuaternionParameterization::ComputeJacobian(const double* x,
double* jacobian) const
{
jacobian[0] = -x[1]; jacobian[1] = -x[2]; jacobian[2] = -x[3]; // NOLINT
jacobian[3] = x[0]; jacobian[4] = x[3]; jacobian[5] = -x[2]; // NOLINT
jacobian[6] = -x[3]; jacobian[7] = x[0]; jacobian[8] = x[1]; // NOLINT
jacobian[9] = x[2]; jacobian[10] = -x[1]; jacobian[11] = x[0]; // NOLINT
return true;
}
调用的方法:1
2ceres::LocalParameterization* local_param = new ceres::QuaternionParameterization();
problem.AddParameterBlock(quaternion, 4, local_param) //重构参数,优化时实际使用的是3维的等效旋转矢量
注意:从 Ceres version 2.2.0开始,LocalParameterization
interface and associated classes are deprecated. Please use Manifold
instead.
参考:LocalParameterization子类说明
Ceres 学习记录(二)
通过Problem::AddResidualBlock()
函数,记录输入parameters和residuals的尺寸
AddParameterBlock
添加的优化变量并不一定是ceres内部运算时采用的优化变量,例如我们通常会采用四元数+平移也就是SE3作为外部维护的状态,在ceres优化中也被成为global parameter,但通常会对 se3(local parameter)求解jacobian,那么此时我们必须要告诉ceres,求解出se3的优化增量后如何对SE3进行更新,这个就是通过指定参数化方式完成的,即传入LocalParameterization的子类对象,
参数化类中需要实现什么?
定义该参数优化过程中,求解出来的Local parameter对Global parameter进行更新的方法,virtual bool Plus(const double* x, const double* delta, double* x_plus_delta) const
定义Global parameter
对Local parameter
的jacobian, virtual bool ComputeJacobian(const double* x, double* jacobian) const
,因为ceres优化时,只能设置残差关于李群的jacobian(通过实现ceres::SizedCostFunction
子类完成),但我们需要的是残差关于李代数的jacobian,因此通过这个函数传入李群与对应李代数的jacobian,从而实现转换。