注意:本文中的代码必须使用OpenCV3.0或以上版本进行编译,因为很多函数是3.0以后才加入的。
目录:
- SfM介绍
- 小孔相机模型
- 坐标系
- 内参矩阵
- 外参矩阵
- 相机的标定
SfM介绍
SfM的全称为Structure from Motion,即通过相机的移动来确定目标的空间和几何关系,是三维重建的一种常见方法。它与Kinect这种3D摄像头最大的不同在于,它只需要普通的RGB摄像头即可,因此成本更低廉,且受环境约束较小,在室内和室外均能使用。但是,SfM背后需要复杂的理论和算法做支持,在精度和速度上都还有待提高,所以目前成熟的商业应用并不多。
本系列介绍SfM中的基本原理与算法,借助OpenCV实现一个简易的SfM系统。
小孔相机模型
在计算机视觉中,最常用的相机模型就是小孔模型(小孔成像模型),它将相机的透镜组简化为一个小孔,光线透过小孔在小孔后方的像面上成像,如下图所示。
由上图可知,小孔模型成的是倒像,为了表述与研究的方便,我们常常将像面至于小孔之前,且到小孔的距离仍然是焦距f,这样的模型与原来的小孔模型是等价的,只不过成的是正像,符合人的直观感受。在这种情况下,往往将小孔称作光心(Optical Center)。
小孔模型是一种理想相机模型,没有考虑实际相机中存在的场曲、畸变等问题。在实际使用时,这些问题可以通过在标定的过程中引入畸变参数解决,所以小孔模型仍然是目前最广泛使用的相机模型。
坐标系
为了用数学研究SfM,我们需要坐标系。在SfM中主要有两类坐标系,一类为相机坐标系,一类为世界坐标系。在本系列中,所以坐标系均为右手坐标系。
相机坐标系以相机的光心(小孔)作为原点,X轴为水平方向,Y轴为竖直方向,Z轴指向相机所观察的方向。
世界坐标系的原点可以任意选择,与相机的具体位置无关。
内参矩阵
设空间中有一点P,若世界坐标系与相机坐标系重合,则该点在空间中的坐标为(X, Y, Z),其中Z为该点到相机光心的垂直距离。设该点在像面上的像为点p,像素坐标为(x, y),那么(X, Y, Z)和(x, y)有什么关系呢?
由上图可知,这是一个简单的相似三角形关系,从而得到
x=fXZ, y=fYZ
但是,图像的像素坐标系原点在左上角,而上面公式假定原点在图像中心,为了处理这一偏移,设光心在图像上对应的像素坐标为
(cx,cy)
,则
x=fXZ+cx, y=fYZ+cy
将以上关系表示为矩阵形式,有
Zxy1=f000f0cxcy1XYZ
其中,将矩阵
K=f000f0cxcy1
称为内参矩阵,因为它只和相机自身的内部参数有关(焦距,光心位置)。
外参矩阵
一般情况下,世界坐标系和相机坐标系不重合,这时,世界坐标系中的某一点P要投影到像面上时,先要将该点的坐标转换到相机坐标系下。设P在世界坐标系中的坐标为X,P到光心的垂直距离为s(即上文中的Z),在像面上的坐标为x,世界坐标系与相机坐标系之间的相对旋转为矩阵R(R是一个三行三列的旋转矩阵),相对位移为向量T(三行一列),则
sx=K[RX+T]
其中
RX+T
即为P在相机坐标系下的坐标,使用齐次坐标改写上式,有
sx=K[RT][X1]
其中
[RT]
是一个三行四列的矩阵,称为外参矩阵,它和相机的参数无关,只与相机在世界坐标系中的位置有关。
相机的标定
相机的标定,即为通过某个已知的目标,求取相机内参矩阵的过程。最常用的标定目标就是棋盘格。用相机对棋盘格从不同角度拍摄多张照片,然后将这些照片导入标定程序或算法,即可自动求出相机的内参。
相机标定的方法和工具,我在这篇文章中已有详细的介绍,这里就不再细述了。在此提醒一下,之后的文章中若无特殊说明,所有相机均假定内参已知。
目录:
- 极线约束与本征矩阵
- 特征点提取与匹配
- 三维重建
- 测试
极线约束与本征矩阵
在三维重建前,我们先研究一下同一点在两个相机中的像的关系。假设在世界坐标系中有一点 p ,坐标为 X ,它在1相机中的像为 x1 ,在2相机中的像为 x2 (注意 x1 和 x2 为齐次坐标,最后一个元素是1),如下图。
设 X 到两个相机像面的垂直距离分别为 s1 和 s2 ,且这两个相机具有相同的内参矩阵 K ,与世界坐标系之间的变换关系分别为 [R1 T1] 和 [R2 T2] ,那么我们可以得到下面两个等式
s1x1=K(R1X+T1)s2x2=K(R2X+T2)
由于K是可逆矩阵,两式坐乘K的逆,有
1
设
K1x1=x′1
,
K1x2=x′2
,则有
s1x′1=R1X+T1s2x′2=R2X+T2
我们一般称
x′1
和
x′2
为归一化后的像坐标,它们和图像的大小没有关系,且原点位于图像中心。
由于世界坐标系可以任意选择,我们将世界坐标系选为第一个相机的相机坐标系,这时
R1=I, T1=0
。上式则变为
s1x′1=Xs2x′2=R2X+T2
将第一式带入第二式,有
s2x′2=s1R2x′1+T2
x′2
和
T2
都是三维向量,它们做外积(叉积)之后得到另外一个三维向量
T2x′2
(其中
为外积的矩阵形式,
T2x′2
代表
T2×x′2
),且该向量垂直于
x′2
和
T2
,再用该向量对等式两边做内积,有
0=s1(T2x′2)TR2x′1
即
x′2T2R2x′1=0
令
E=T2R2
有
x′2Ex′1=0
可以看出,上式是同一点在两个相机中的像所满足的关系,它和点的空间坐标、点到相机的距离均没有关系,我们称之为极线约束,而矩阵
E
则称为关于这两个相机的本征矩阵。如果我们知道两幅图像中的多个对应点(至少5对),则可以通过上式解出矩阵
E
,又由于
E
是由
T2
和
R2
构成的,可以从E中分解出
T2
和
R2
。
如何从
E
中分解出两个相机的相对变换关系(即
T2
和
R2
),背后的数学原理比较复杂,好在OpenCV为我们提供了这样的方法,在此就不谈原理了。
特征点提取与匹配
从上面的分析可知,要求取两个相机的相对关系,需要两幅图像中的对应点,这就变成的特征点的提取和匹配问题。对于图像差别较大的情况,推荐使用SIFT特征,因为SIFT对旋转、尺度、透视都有较好的鲁棒性。如果差别不大,可以考虑其他更快速的特征,比如SURF、ORB等。
本文中使用SIFT特征,由于OpenCV3.0将SIFT包含在了扩展部分中,所以官网上下载的版本是没有SIFT的,为此需要到这里下载扩展包,并按照里面的说明重新编译OpenCV(哎~真麻烦,-_-!)。如果你使用其他特征,就不必为此辛劳了。
下面的代码负责提取图像特征,并进行匹配。
void extract_features(
vector<string>& image_names,
vector<vector>& key_points_for_all,
vector& descriptor_for_all,
vector<vector>& colors_for_all
)
{
key_points_for_all.clear();
descriptor_for_all.clear();
Mat image;
Ptr sift = xfeatures2d::SIFT::create(0, 3, 0.04, 10);
for (auto it = image_names.begin(); it != image_names.end(); ++it)
{
image = imread(*it);
if (image.empty()) continue;
vector key_points;
Mat descriptor;
sift->detectAndCompute(image, noArray(), key_points, descriptor);
if (key_points.size() <= 10) continue;
key_points_for_all.push_back(key_points);
descriptor_for_all.push_back(descriptor);
vector colors(key_points.size());
for (int i = 0; i < key_points.size(); ++i)
{
Point2f& p = key_points[i].pt;
colors[i] = image.at(p.y, p.x);
}
colors_for_all.push_back(colors);
}
}
void match_features(Mat& query, Mat& train, vector& matches)
{
vector<vector> knn_matches;
BFMatcher matcher(NORM_L2);
matcher.knnMatch(query, train, knn_matches, 2);
float min_dist = FLT_MAX;
for (int r = 0; r < knn_matches.size(); ++r)
{
if (knn_matches[r][0].distance > 0.6*knn_matches[r][1].distance)
continue;
float dist = knn_matches[r][0].distance;
if (dist < min_dist) min_dist = dist;
}
matches.clear();
for (size_t r = 0; r < knn_matches.size(); ++r)
{
if (
knn_matches[r][0].distance > 0.6*knn_matches[r][1].distance ||
knn_matches[r][0].distance > 5 * max(min_dist, 10.0f)
)
continue;
matches.push_back(knn_matches[r][0]);
}
}
需要重点说明的是,匹配结果往往有很多误匹配,为了排除这些错误,这里使用了Ratio Test方法,即使用KNN算法寻找与该特征最匹配的2个特征,若第一个特征的匹配距离与第二个特征的匹配距离之比小于某一阈值,就接受该匹配,否则视为误匹配。当然,也可以使用Cross Test(交叉验证)方法来排除错误。
得到匹配点后,就可以使用OpenCV3.0中新加入的函数findEssentialMat()来求取本征矩阵了。得到本征矩阵后,再使用另一个函数对本征矩阵进行分解,并返回两相机之间的相对变换R和T。注意这里的T是在第二个相机的坐标系下表示的,也就是说,其方向从第二个相机指向第一个相机(即世界坐标系所在的相机),且它的长度等于1。
bool find_transform(Mat& K, vector& p1, vector& p2, Mat& R, Mat& T, Mat& mask)
{
double focal_length = 0.5*(K.at<double>(0) + K.at<double>(4));
Point2d principle_point(K.at<double>(2), K.at<double>(5));
Mat E = findEssentialMat(p1, p2, focal_length, principle_point, RANSAC, 0.999, 1.0, mask);
if (E.empty()) return false;
double feasible_count = countNonZero(mask);
cout << (int)feasible_count << " -in- " << p1.size() << endl;
if (feasible_count <= 15 || (feasible_count / p1.size()) < 0.6)
return false;
int pass_count = recoverPose(E, p1, p2, R, T, focal_length, principle_point, mask);
if (((double)pass_count) / feasible_count < 0.7)
return false;
return true;
}
三维重建
现在已经知道了两个相机之间的变换矩阵,还有每一对匹配点的坐标。三维重建就是通过这些已知信息还原匹配点在空间当中的坐标。在前面的推导中,我们有
s2x2=K(R2X+T2)
这个等式中有两个未知量,分别是
s2
和
X
。用
x2
对等式两边做外积,可以消去
s2
,得
0=x2K(R2X+T2)
整理一下可以得到一个关于空间坐标X的线性方程
x2KR2X=x2T2
解上述方程,即可求取X。其几何意义相当于分别从两个相机的光心作过
x1
和
x2
的延长线,延长线的焦点即为方程的解,如文章最上方的图所示。由于这种方法和三角测距类似,因此这种重建方式也被称为三角化(triangulate)。OpenCV提供了该方法,可以直接使用。
void reconstruct(Mat& K, Mat& R, Mat& T, vector& p1, vector& p2, Mat& structure)
{
Mat proj1(3, 4, CV_32FC1);
Mat proj2(3, 4, CV_32FC1);
proj1(Range(0, 3), Range(0, 3)) = Mat::eye(3, 3, CV_32FC1);
proj1.col(3) = Mat::zeros(3, 1, CV_32FC1);
R.convertTo(proj2(Range(0, 3), Range(0, 3)), CV_32FC1);
T.convertTo(proj2.col(3), CV_32FC1);
Mat fK;
K.convertTo(fK, CV_32FC1);
proj1 = fK*proj1;
proj2 = fK*proj2;
triangulatePoints(proj1, proj2, p1, p2, structure);
}
测试
我用了下面两幅图像进行测试
得到了着色后的稀疏点云,是否能看出一点轮廓呢?!
图片中的两个彩色坐标系分别代表两个相机的位置。
在接下来的文章中,会将相机的个数推广到任意多个,成为一个真正的SfM系统。
关于源代码的使用
代码是用VS2013写的,OpenCV版本为3.0且包含扩展部分,如果不使用SIFT特征,可以修改源代码,然后使用官方未包含扩展部分的库。软件运行后会将三维重建的结果写入Viewer目录下的structure.yml文件中,在Viewer目录下有一个SfMViewer程序,直接运行即可读取yml文件并显示三维结构。
目录:
- 问题简化
- 求第三个相机的变换矩阵
- 加入更多图像
- 代码实现
- 测试
- 思考
- 下载
问题简化
终于有时间来填坑了,这次一口气将双目重建扩展为多目重建吧。首先,为了简化问题,我们要做一个重要假设:用于多目重建的图像是有序的,即相邻图像的拍摄位置也是相邻的。多目重建本身比较复杂,我会尽量说得清晰,如有表述不清的地方,还请见谅并欢迎提问。
求第三个相机的变换矩阵
由前面的文章我们知道,两个相机之间的变换矩阵可以通过findEssentialMat以及recoverPose函数来实现,设第一个相机的坐标系为世界坐标系,现在加入第三幅图像(相机),如何确定第三个相机(后面称为相机三)到到世界坐标系的变换矩阵呢?
最简单的想法,就是沿用双目重建的方法,即在第三幅图像和第一幅图像之间提取特征点,然后调用findEssentialMat和recoverPose。那么加入第四幅、第五幅,乃至更多呢?随着图像数量的增加,新加入的图像与第一幅图像的差异可能越来越大,特征点的提取变得异常困难,这时就不能再沿用双目重建的方法了。
那么能不能用新加入的图像和相邻图像进行特征匹配呢?比如第三幅与第二幅匹配,第四幅与第三幅匹配,以此类推。当然可以,但是这时就不能继续使用findEssentialMat和recoverPose来求取相机的变换矩阵了,因为这两个函数求取的是相对变换,比如相机三到相机二的变换,而我们需要的是相机三到相机一的变换。有人说,既然知道相机二到相机一的变换,又知道相机到三到相机二的变换,不就能求出相机三到相机一的变换吗?实际上,通过这种方式,你只能求出相机三到相机一的旋转变换(旋转矩阵R),而他们之间的位移向量T,是无法求出的。这是因为上面两个函数求出的位移向量,都是单位向量,丢失了相机之间位移的比例关系。
说了这么多,我们要怎么解决这些问题?现在请出本文的主角——solvePnP和solvePnPRansac。根据opencv的官方解释,该函数根据空间中的点与图像中的点的对应关系,求解相机在空间中的位置。也就是说,我知道一些空间当中点的坐标,还知道这些点在图像中的像素坐标,那么solvePnP就可以告诉我相机在空间当中的坐标。solvePnP和solvePnPRansac所实现的功能相同,只不过后者使用了随机一致性采样,使其对噪声更鲁棒,本文使用后者。
好了,有这么好的函数,怎么用于我们的三维重建呢?首先,使用双目重建的方法,对头两幅图像进行重建,这样就得到了一些空间中的点,加入第三幅图像后,使其与第二幅图像进行特征匹配,这些匹配点中,肯定有一部分也是图像二与图像一之间的匹配点,也就是说,这些匹配点中有一部分的空间坐标是已知的,同时又知道这些点在第三幅图像中的像素坐标,嗯,solvePnP所需的信息都有了,自然第三个相机的空间位置就求出来了。由于空间点的坐标都是世界坐标系下的(即第一个相机的坐标系),所以由solvePnP求出的相机位置也是世界坐标系下的,即相机三到相机一的变换矩阵。
加入更多图像
通过上面的方法得到相机三的变换矩阵后,就可以使用上一篇文章提到的triangulatePoints方法将图像三和图像二之间的匹配点三角化,得到其空间坐标。为了使之后的图像仍能使用以上方法求解变换矩阵,我们还需要将新得到的空间点和之前的三维点云融合。已经存在的空间点,就没必要再添加了,只添加在图像二和三之间匹配,但在图像一和图像三中没有匹配的点。如此反复。
为了方便点云的融合以及今后的扩展,我们需要存储图像中每个特征点在空间中的对应点。在代码中我使用了一个二维列表,名字为correspond_struct_idx,correspond_struct_idx[i][j]代表第i幅图像第j个特征点所对应的空间点在点云中的索引,若索引小于零,说明该特征点在空间当中没有对应点。通过此结构,由特征匹配中的queryIdx和trainIdx就可以查询某个特征点在空间中的位置。
代码实现
前一篇文章的很多代码不用修改,还可以继续使用,但是程序的流程有了较大变化。首先是初始化点云,也就是通过双目重建方法对图像序列的头两幅图像进行重建,并初始化correspond_struct_idx。
void init_structure(
Mat K,
vector<vector>& key_points_for_all,
vector<vector>& colors_for_all,
vector<vector>& matches_for_all,
vector& structure,
vector<vector<int>>& correspond_struct_idx,
vector& colors,
vector& rotations,
vector& motions
)
{
vector p1, p2;
vector c2;
Mat R, T;
Mat mask;
get_matched_points(key_points_for_all[0], key_points_for_all[1], matches_for_all[0], p1, p2);
get_matched_colors(colors_for_all[0], colors_for_all[1], matches_for_all[0], colors, c2);
find_transform(K, p1, p2, R, T, mask);
maskout_points(p1, mask);
maskout_points(p2, mask);
maskout_colors(colors, mask);
Mat R0 = Mat::eye(3, 3, CV_64FC1);
Mat T0 = Mat::zeros(3, 1, CV_64FC1);
reconstruct(K, R0, T0, R, T, p1, p2, structure);
rotations = { R0, R };
motions = { T0, T };
correspond_struct_idx.clear();
correspond_struct_idx.resize(key_points_for_all.size());
for (int i = 0; i < key_points_for_all.size(); ++i)
{
correspond_struct_idx[i].resize(key_points_for_all[i].size(), -1);
}
int idx = 0;
vector& matches = matches_for_all[0];
for (int i = 0; i < matches.size(); ++i)
{
if (mask.at(i) == 0)
continue;
correspond_struct_idx[0][matches[i].queryIdx] = idx;
correspond_struct_idx[1][matches[i].trainIdx] = idx;
++idx;
}
}
初始点云得到后,就可以使用增量方式重建剩余图像,注意,在代码中为了方便实现,所有图像之间的特征匹配已经事先完成了,并保存在matches_for_all这个列表中。增量重建的关键是调用solvePnPRansac,而这个函数需要空间点坐标和对应的像素坐标作为参数,有了correspond_struct_idx,实现这个对应关系的查找还是很方便的,如下。
void get_objpoints_and_imgpoints(
vector& matches,
vector<int>& struct_indices,
vector& structure,
vector& key_points,
vector& object_points,
vector& image_points)
{
object_points.clear();
image_points.clear();
for (int i = 0; i < matches.size(); ++i)
{
int query_idx = matches[i].queryIdx;
int train_idx = matches[i].trainIdx;
int struct_idx = struct_indices[query_idx];
if (struct_idx < 0) continue;
object_points.push_back(structure[struct_idx]);
image_points.push_back(key_points[train_idx].pt);
}
}
之后调用solvePnPRansac得到相机的旋转向量和位移,由于我们使用的都是旋转矩阵,所以这里要调用opencv的Rodrigues函数将旋转向量变换为旋转矩阵。之后,使用上一篇文章中用到的reconstruct函数对匹配点进行重建(三角化),不过为了适用于多目重建,做了一些简单修改。
void reconstruct(Mat& K, Mat& R1, Mat& T1, Mat& R2, Mat& T2, vector& p1, vector& p2, vector& structure)
{
Mat proj1(3, 4, CV_32FC1);
Mat proj2(3, 4, CV_32FC1);
R1.convertTo(proj1(Range(0, 3), Range(0, 3)), CV_32FC1);
T1.convertTo(proj1.col(3), CV_32FC1);
R2.convertTo(proj2(Range(0, 3), Range(0, 3)), CV_32FC1);
T2.convertTo(proj2.col(3), CV_32FC1);
Mat fK;
K.convertTo(fK, CV_32FC1);
proj1 = fK*proj1;
proj2 = fK*proj2;
Mat s;
triangulatePoints(proj1, proj2, p1, p2, s);
structure.clear();
structure.reserve(s.cols);
for (int i = 0; i < s.cols; ++i)
{
Mat_<float> col = s.col(i);
col /= col(3);
structure.push_back(Point3f(col(0), col(1), col(2)));
}
}
最后,将重建结构与之前的点云进行融合。
void fusion_structure(
vector& matches,
vector<int>& struct_indices,
vector<int>& next_struct_indices,
vector& structure,
vector& next_structure,
vector& colors,
vector& next_colors
)
{
for (int i = 0; i < matches.size(); ++i)
{
int query_idx = matches[i].queryIdx;
int train_idx = matches[i].trainIdx;
int struct_idx = struct_indices[query_idx];
if (struct_idx >= 0)
{
next_struct_indices[train_idx] = struct_idx;
continue;
}
structure.push_back(next_structure[i]);
colors.push_back(next_colors[i]);
struct_indices[query_idx] = next_struct_indices[train_idx] = structure.size() - 1;
}
}
整个增量方式重建图像的代码大致如下。
init_structure(
K,
key_points_for_all,
colors_for_all,
matches_for_all,
structure,
correspond_struct_idx,
colors,
rotations,
motions
);
for (int i = 1; i < matches_for_all.size(); ++i)
{
vector object_points;
vector image_points;
Mat r, R, T;
get_objpoints_and_imgpoints(
matches_for_all[i],
correspond_struct_idx[i],
structure,
key_points_for_all[i+1],
object_points,
image_points
);
solvePnPRansac(object_points, image_points, K, noArray(), r, T);
Rodrigues(r, R);
rotations.push_back(R);
motions.push_back(T);
vector p1, p2;
vector c1, c2;
get_matched_points(key_points_for_all[i], key_points_for_all[i + 1], matches_for_all[i], p1, p2);
get_matched_colors(colors_for_all[i], colors_for_all[i + 1], matches_for_all[i], c1, c2);
vector next_structure;
reconstruct(K, rotations[i], motions[i], R, T, p1, p2, next_structure);
fusion_structure(
matches_for_all[i],
correspond_struct_idx[i],
correspond_struct_idx[i + 1],
structure,
next_structure,
colors,
c1
);
}
测试
我用了八幅图像进行测试,正如问题简化中所要求的那样,图像是有序的。
程序的大部分时间花在特征提取和匹配上,真正的重建过程耗时很少。最终结果如下。
图中每个彩色坐标系都代表一个相机。
思考
- 这个多目三维重建程序,要求图像必须是有序的,如果图像无序,比如只是对某个目标在不同角度的随意拍摄,程序应该如何修改?
- 增量式三维重建方法,有一个很大的缺点——随着图像的不断增加,误差会不断累积,最后误差过大以至于完全偏离重建的目标,怎么解决?
有兴趣的读者可以思考一下上面两个问题,第二个问题比较难,我会在下一篇文章中详细介绍。
下载
程序使用VS2015开发,OpenCV版本为3.1且包含扩展部分,如果不使用SIFT特征,可以修改源代码,然后使用官方未包含扩展部分的库。软件运行后会将三维重建的结果写入Viewer目录下的structure.yml文件中,在Viewer目录下有一个SfMViewer程序,直接运行即可读取yml文件并显示三维结构。