参考资料:
  • 《OpenCV 2 Computer Vision Application Programming Cookbook》
  • 《The OpenCV Reference Manual》

读取视频

使用 CV::VideoCapture 来读取视频序列。

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
#include <stdio.h>
#include <string>
#include <opencv2/imgproc/imgproc.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>

int main()
{
// Open the video file
cv::VideoCapture capture("../stomp.avi");
// check if video successfully opened
if (!capture.isOpened())
return 1;
// Get the frame rate
double rate = capture.get(CV_CAP_PROP_FPS);

bool stop(false);
cv::Mat frame; // current video frame
cv::namedWindow("Extracted Frame");
// Delay between each frame in ms
// corresponds to video frame rate
int delay = 1000 / rate;
// for all frames in video
while (!stop){
// read next frame if any
if (!capture.read(frame))
break;
cv::imshow("Extracted Frame", frame);
// introduce a delay
// or press key to stop
if (cv::waitKey(delay) >= 0)
stop = true;
}
// Close the video file
// Not required since called by destructor
capture.release();
}

也可以通过类似的方法读入摄像头捕捉的视频,要改动的地方仅仅是将上面的视频文件名改为摄像头的 ID,默认的摄像头 ID 为 0。

写入视频

使用 CV::VideoWriter 来写入视频。

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
#include <iostream>
#include <iomanip>
#include <sstream>
#include <string>
#include <vector>
#include <dbg.h>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>

using namespace cv;
using namespace std;

int main()
{
VideoCapture cap;
cap.open(0);
namedWindow("MyVideo", 1);
double dWidth = cap.get(CV_CAP_PROP_FRAME_WIDTH);
double dHeight = cap.get(CV_CAP_PROP_FRAME_HEIGHT);
Size frameSize(static_cast<int>(dWidth), static_cast<int>(dHeight));

VideoWriter oVideoWriter("MyVideo.avi", CV_FOURCC('P','I','M','1'), 20, frameSize, true); // initialize the VideoWriter objetct

while (1) {
Mat frame;
bool bSuccess = cap.read(frame);
if (!bSuccess){ // if not success, break loop
cout << "ERROR: cannot read a frame from video file" << endl;
break;
}

oVideoWriter.write(frame);
imshow("MyVideo", frame); // show the frame in "MyVideo" window

if(waitKey(10) == 27) {// wait for ESC key
cout << "ESC key is pressed by user" << endl;
break;
}
}

return 0;
}

目标检测

mean-shift 和 camshift

均值漂移(mean-shift)

mean-shift 算法是一种在一组数据的密度分布中寻找局部极值的稳定 [1] 的方法。若分布是连续的,处理过程就比较容易,这种情况下本质上只需要对数据的密度直方图应用爬山算法即可。然而,对于离散的数据集,这个问题在某种程度上是比较麻烦的。

mean-shift 算法的步骤如下:

  1. 选择搜索窗口。
  2. 计算窗口(可能是带权重的)的重心。
  3. 将窗口的中心设置在计算出的重心处。
  4. 返回第 2 步,直到窗口的位置不再变化(通常会)。

OpenCV 提供 cv::meanshift() 函数来进行 mean-shift 算法跟踪。

1
int cv::meanShift(InputArray probImage, Rect& window, TermCriteria criteria)

其中,

  • probImage - 图像直方图反投影后的结果;
  • window - 初始的查找窗口,即要跟踪的区域;
  • criteria - 迭代搜索算法的终止条件,主要由 mean-shift 移动的最大迭代次数和可视为窗口位置收敛的最小移动距离组成。
  • 返回的是收敛时算法的迭代次数。

对于第一个参数 probImage ,可以直接使用 cv::calcBackProject() 得到的结果。但《OpenCV 2 Computer Vision Application Programming Cookbook》建议先把图像转换到 HSV 颜色空间,然后使用 Hue 单通道的直方图的反投影变换结果作为 probImage

获取彩色图像的 Hue 通道的直方图算法实现如下:

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
// Computes the 1D Hue histogram with a mask.
// BGR source image is converted to HSV
cv::MatND getHueHistogram(const cv::Mat &image,
int minSaturation = 0) {

cv::MatND hist;

// Convert to HSV color space
cv::Mat hsv;
cv::cvtColor(image, hsv, CV_BGR2HSV);

// Mask to be used (or not)
cv::Mat mask;

if (minSaturation > 0) {
// Spliting the 3 channels into 3 images
std::vector<cv::Mat> v;
cv::split(hsv, v);
// Mask out the low saturated pixels
cv::threshold(v[1], mask, minSaturation,
255, cv::THRESH_BINARY);
}

// Prepare arguments for a 1D hue histogram
hranges[0]= 0.0;
hranges[1]= 180.0;
channels[0]= 0; // the hue channel

// Compute histogram
cv::calcHist(&hsv,
1, // histogram of 1 image only
channels, // the channel used
mask, // no mask is used
hist, // the resulting histogram
1, // it is a 1D histogram
histSize, // number of bins
ranges // pixel value range
);

return hist;
}

使用 cv::meanshift() 函数在两幅图像间跟踪某一物体的步骤如下:

  1. 读入第一张图像,定义好目标跟踪窗口,即感兴趣区域 ROI 。
  2. 计算这个 ROI 的 Hue 通道的直方图;
  3. 读入第二张图像,转换到 HSV 颜色空间,并对其 Hue 分量进行阈值处理 cv::threshold(),去掉低饱和度的像素,以保证较高的饱和度;
  4. 应用 ROI 的 直方图信息对第 3 步得到的图像进行反投影变换;
  5. 对反投影变换的结果在进行一次按位与操作 cv::bitwise_and() ,去除结果中低饱和度的像素;
  6. 最后,对第 5 步得到的结果进行 meanshift 操作 cv::meanShift(),得到新的跟踪窗口。

实现如下:

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
int main()
{
// Read reference image
cv::Mat image= cv::imread("../baboon1.jpg");
if (!image.data)
return 0;

// Define ROI
cv::Mat imageROI= image(cv::Rect(110,260,35,40));
cv::rectangle(image, cv::Rect(110,260,35,40), cv::Scalar(0,0,255));

// Display image
cv::namedWindow("Image 1");
cv::imshow("Image 1",image);

// Get the Hue histogram
int minSat=65;
ColorHistogram hc;
cv::MatND colorhist= hc.getHueHistogram(imageROI, minSat);

ObjectFinder finder;
finder.setHistogram(colorhist);
finder.setThreshold(0.2f);

// Second image
image= cv::imread("../baboon3.jpg");

// Display image
cv::namedWindow("Image 2");
cv::imshow("Image 2",image);

// Convert to HSV space
cv::Mat hsv;
cv::cvtColor(image, hsv, CV_BGR2HSV);

// Split the image
vector<cv::Mat> v;
cv::split(hsv,v);

// Eliminate pixels with low saturation
cv::threshold(v[1],v[1],minSat,255,cv::THRESH_BINARY);
cv::namedWindow("Saturation");
cv::imshow("Saturation",v[1]);

// Get back-projection of hue histogram
int ch[1]={0};
cv::Mat result= finder.find(hsv,0.0f,180.0f,ch,1);

cv::namedWindow("Result Hue");
cv::imshow("Result Hue",result);

// Eliminate low stauration pixels
cv::bitwise_and(result,v[1],result);
cv::namedWindow("Result Hue and raw");
cv::imshow("Result Hue and raw",result);

cv::Rect rect(110,260,35,40);
cv::rectangle(image, rect, cv::Scalar(0,0,255));

cv::TermCriteria criteria(cv::TermCriteria::MAX_ITER,10,0.01);
cout << "meanshift= " << cv::meanShift(result,rect,criteria) << endl;

cv::rectangle(image, rect, cv::Scalar(0,255,0));

// Display image
cv::namedWindow("Image 2 result");
cv::imshow("Image 2 result",image);

cv::waitKey();
return 0;
}

《The OpenCV Reference Manual》里还建议再对反投影的结果进行降噪处理。例如,可以先使用 cv::findContours() 检索到一些闭合的轮廓边,使用 cv::contourArea() 去掉一些面积较小的轮廓边,再使用 cv::drawContours() 填充剩下的轮廓边。

camshift

camshift 是“continuously adaptive mean-shift”的缩写,顾名思义就是 mean-shift 的一个改进版本,它的实现基于 meanshift ,而与 meanshift 的最大区别在于窗口的大小和朝向是可变的。如果有一个易于分割的分布(例如保持紧密的人脸特征),此算法可以根据人在走近或远离摄像机时脸的尺寸而自动调整窗口的尺寸。

OpenCV 提供 cv::Camshift() 函数来进行 mean-shift 算法跟踪。

1
RotatedRect CamShift(InputArray probImage, Rect& window, TermCriteria criteria)

该函数将返回一个经过旋转的矩形结构体 RotatedRect ,该结构体包含了目标的位置、大小和朝向信息。可以通过 RotatedRect::boundingRect() 函数获得查找窗口的下一个位置。

OpenCV 自带的示例程序中包含一个应用 camshift 捕捉彩色物体的示例 camshiftdemo.c 。

特征匹配

Harris 角点

经典版本

1
2
3
4
5
6
void cornerHarris(InputArray src,	// input
OutputArray dst, // output
int blockSize, // neighborhood size
int ksize, // aperture size
double k, // Harris parameter
int borderType=BORDER_DEFAULT )

示例:

1
2
3
4
5
6
7
8
9
10
11
// Detect Harris Corners
cv::Mat cornerStrength;
cv::cornerHarris(image,cornerStrength,
3, // neighborhood size
3, // aperture size
0.01); // Harris parameter
// threshold the corner strengths
cv::Mat harrisCorners;
double threshold= 0.0001;
cv::threshold(cornerStrength,harrisCorners,
threshold,255,cv::THRESH_BINARY_INV);

原图:

结果图:

与原图结合:

改进版本

用上面的方法得到的 Harris 角点在图像中的分布不是很均匀,一个改进的方法是使用 cv::goodFeaturesToTrack() 方法(听名字就很彪悍),用它可以获得更加 strong 的角点:

1
2
3
4
5
6
7
8
9
10
11
void goodFeaturesToTrack(InputArray image,	// input 8-bit or floating-point 32-bit, single-channel image
OutputArray corners, // output vector of detected corners.

int maxCorners, // maximum number of corners to return
double qualityLevel, // quality level
double minDistance, // minimum allowed distance between points
InputArray mask=noArray(),// optional region of interest
int blockSize=3, // size of an average block
bool useHarrisDetector=false, // whether to use a classical Harris detector
double k=0.04 ) // free parameter of the Harris detector

示例:

1
2
3
4
5
6
// Compute good features to track
std::vector<cv::Point2f> corners;
cv::goodFeaturesToTrack(imaeg, corners,
500, // maximum number of corners to return
0.01, // quality level
10); // minimum allowed distance between points

结果:

这种方法得到的 Harris 角点在分布均匀程度上得到了很明显的改进。不过,这种改进的方案计算复杂度也更高,因为它需要对检测到的特征点按照 Harris 得分进行排序。另外,如果把第 8 个参数 useHarrisDetector 的值设为 true,则用的将是经典的 Harris 检测器。

通用接口

为了方便使用这些特征检测算法,OpenCV 2 提供了特征点检测的通用接口,封装在抽象类 cv::FeatureDetector 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CV_EXPORTS FeatureDetector
{
public:
virtual ~FeatureDetector();
void detect( const Mat& image, vector<KeyPoint>& keypoints,
const Mat& mask=Mat() ) const;
void detect( const vector<Mat>& images,
vector<vector<KeyPoint> >& keypoints,
const vector<Mat>& masks=vector<Mat>() ) const;
virtual void read(const FileNode&);
virtual void write(FileStorage&) const;
static Ptr<FeatureDetector> create( const string& detectorType );
protected:
...
};

其中:

  • create() 函数用于创建一个特征检测器,其参数 detectorType 可以是以下几种:
    • “FAST” – FastFeatureDetector
    • “STAR” – StarFeatureDetector
    • “SIFT” – SIFT (nonfree module)
    • “SURF” – SURF (nonfree module)
    • “ORB” – ORB
    • “BRISK” – BRISK
    • “MSER” – MSER
    • “GFTT” – GoodFeaturesToTrackDetector
    • “HARRIS” – GoodFeaturesToTrackDetector with Harris detector enabled
    • “Dense” – DenseFeatureDetector
    • “SimpleBlob” – SimpleBlobDetector
  • 两个 detect() 函数用于进行特征点检测;
  • read()write() 函数用于将检测到的特征点进行文件读/写。

为了方便存储不同的特征点的信息,OpenCV 还为这个接口还定义了一个 Keypoint 类,用于存储特征点的所有相关属性。例如,对于 Harris 角点,该类的实例将会用来储存检测得到的特征点的位置。

另外,OpenCV 为每种特征点提供了从 cv::FeatureDetector 继承而来的子类。例如,改进的 Harris 角点对应子类 cv::GoodFeatureToTrackDetector 。使用方法如下:

1
2
3
4
5
6
7
8
9
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construction of the Good Feature to Track detector
cv::GoodFeatureToTrackDetector gftt (
500, // maximum number of corners to be returned
0.01, // quality level
10); // minimum allowed distance between points
// point detection using FeatureDetector method
gftt.detect(image, keypoints);

OpenCV 还提供了一个通用方法 cv::drawKeyPoints 用于在图像上绘制得到的特征点:

1
2
3
4
5
cv::drawKeyPoints(image,		// original image
keypoints, // vector of keypoints
image, // the output image
cv::Scalar(255, 255, 255), // keypoint color
cv::DrawMatchesFlags::DRAW_OVER_OUTIMG); // drawing flag

这些类的使用方法大同小异。

FAST 特征点

FAST 特征点是为了提高检测速度而设计的。使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construction of the Fast feature detector object
cv::FastFeatureDetector fast(
40); // threshold for detection
// feature point detection
fast.detect(image, keypoints);
// Draw key points
cv::drawKeyPoints(image, // original image
keypoints, // vector of keypoints
image, // the output image
cv::Scalar(255, 255, 255), // keypoint color
cv::DrawMatchesFlags::DRAW_OVER_OUTIMG); // drawing flag

SURF 特征点

SURF(Speeded Up Robust Features) 特征点是一种尺度不变,且运算效率快的特征点。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construct the SURF feature detector object
cv::SurfFeatureDetector surf(
2500.); // threshold
// Detect the SURF features
surf.detect(image, keypoints);
// Draw the keypoints with scale and orientation information
cv::drawKeypoints(image, // original image
keypoints, // vector of keypoints
featureImage, // the resulting image
cv::Scalar(255,255,255), // color of the points
cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS); //flag

对另一幅尺度不同的图使用 SURF 特征检测,得到结果如下:

仔细对比两幅图,可以发现当图像的尺度发生变化后,两幅图像存在对应的圆圈也会跟着改变大小。尽管不是全部特征点都有对应,但通常存在对应的特征点的数量已经足够用在两幅图像间的比配。

SIFT 特征点

SURF 的一个发展是另一个著名的特征点 —— SIFT (Scale-Invariant Feature Transform) 。

使用示例:

1
2
3
4
5
6
7
8
9
10
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construct the SURF feature detector object
cv::SiftFeatureDetector sift{
0.03, // feature threshold
10.); // threshold to reduce
// sensitivity to lines
// Detect the SURF features
sift.detect(image, keypoints);
}

结果和 SURF 提取的结果很相似:

SIFT 特征点比 SURF 更精确,不过速度上也慢一些。

特征匹配

特征点的一大用处是用来进行特征匹配,例如寻找同个场景在两个不同角度的照片的对应关系。

在特征匹配中,特征描述子(feature descriptors)通常是一个用于描述一个特征点的 N 维向量,且在理想情况下该向量可以对光照的变化和小幅度的形变具有不变性。另外,描述子的好坏还可以通过一个距离方程(例如欧氏距离)来比较和度量。因此,特征描述子是特征匹配算法中的一个强有力的工具。

同样,OpenCV 2 提供了一个通用的接口类 cv::DescriptorExtractor 用于构造特征描述子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CV_EXPORTS DescriptorExtractor
{
public:
virtual ~DescriptorExtractor();
void compute( const Mat& image, vector<KeyPoint>& keypoints,
Mat& descriptors ) const;
void compute( const vector<Mat>& images, vector<vector<KeyPoint> >& keypoints,
vector<Mat>& descriptors ) const;
virtual void read( const FileNode& );
virtual void write( FileStorage& ) const;
virtual int descriptorSize() const = 0;
virtual int descriptorType() const = 0;
static Ptr<DescriptorExtractor> create( const string& descriptorExtractorType );
protected:
...
};

其中:

  • create() 函数用于创建一个特征描述子,其参数 detectorType 可以是以下几种:
    • “SIFT” – SIFT
    • “SURF” – SURF
    • “ORB” – ORB
    • “BRISK” – BRISK
    • “BRIEF” – BriefDescriptorExtractor
  • 两个 compute() 函数用于从一组检测得到的特征点计算特征描述子;
  • read()write() 函数用于将检测到的特征点进行文件读/写;

示例:

1
2
3
4
5
// Construction of the SURF descriptor extractor
cv::SurfDescriptorExtractor surfDesc;
// Extraction of the SURF descriptors
cv::Mat descriptors1;
surfDesc.compute(image1,keypoints1,descriptors1);

结果是一个行数和特征点数量相同的矩阵,每一行都是一个 N 维 的描述子的 vector (以 SURF 描述子为例,N 默认为 64) 。每个 vector 可以很好的描述一个特征点周围的色值信息。两个特征点越相似,则它们的特征描述子越接近。

特征描述子在图像匹配中尤其有用。OpenCV 提供了一个最基本的匹配算法 cv::BruteForceMatcher() ,用法如下:

1
2
3
4
5
// Construction of the matcher
cv::BruteForceMatcher<cv::L2<float>> matcher;
// Match the two image descriptors
std::vector<cv::DMatch> matches;
matcher.match(descriptors1,descriptors2, matches);

同样,OpenCV 也提供了一个通用接口类 cv::DescriptorMatcher ,封装了多种匹配方法。

得到的匹配结果是一个 cv::DMatch 类型的数据结构,这个结构体是一种 pair 类型 ,每一个 pair 分别包含第一个 vector 和第二个 vector 的匹配到的元素的序号。

OpenCV 也提供了函数用于绘制匹配结果 cv::drawMatches ,下面示例将上面的代码得到的结果的前 25 个配对给绘制出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::nth_element(matches.begin(),	// initial position
matches.begin()+24, // position of the sorted element
matches.end()); // end position
// remove all elements after the 25th
matches.erase(matches.begin()+25, matches.end());
// Visualize matches
cv::Mat imageMatches;
cv::drawMatches(
image1,keypoints1, // 1st image and its keypoints
image2,keypoints2, // 2nd image and its keypoints
matches, // the matches
imageMatches, // the image produced
cv::Scalar(255,255,255)); // color of the lines

结果如下:

相机校正

畸变的由来

计算机视觉领域使用的相机模型称为针孔相机模型(pin-hole camera):

理想的针孔相机
理想的针孔相机

基本的投影方程如下:

\[ h_i = f\frac{h_o}{d_o} \]

其中,图像的大小 (\(h_i\)) 与物体的大小 (\(h_o\)) 沿着与焦点的距离 (\(d_o\)) 成比例关系。

根据前面所提的背景知识,我们可以清楚地认识到在针孔相机模型里的关键参数就是相机的焦距和它的成像大小。另外,因为我们要处理的是数字图像,相机的像素数量也是一个相机的另一个重要参数。最后,为了计算场景中的一点在像素坐标中的位置,我们还需要一个额外的信息:考虑那条从焦点引出的与成像平面垂直的直线,我们需要知道这条线穿入成像平面的哪个像素点,这个点称为 主要点 (principal point)。逻辑上,这个主要点应该设在成像平面的中心,但实际上这个点往往会由于机械原因,难免会偏移一些像素,这就是畸变的由来。

带有畸变的针孔相机
带有畸变的针孔相机

校正方法

检测棋盘角点

对相机进行畸变校正的主要思想是给相机展示一些三维空间间位置已知的点,之后寻找这些点在图像上的相应投影位置,最后根据这些对应关系计算相机的相关信息。OpenCV 建议通过从不同角度获取一个棋盘模板图像来校正相机。在这个情况下,可以利用每张棋盘图像上的角点来获取实际位置和投影位置的对应关系,从而计算相机的相关信息。一个示例棋盘模板:

从上面这张图也可以很明显的看出相机存在较严重的径向畸变(radial distortion),这种畸变现象在鱼眼镜头中非常普遍。

OpenCV 提供了一个自动检测棋盘模板中的角点的函数 cv::findChessboardCorners()

1
2
3
4
5
bool findChessboardCorners(InputArray image,	// source chessboard view
Size patternSize, // number of inner corners per a chessboard row and column
OutputArray corners, // output array of detected corners
int flags=CALIB_CB_ADAPTIVE_THRESH+CALIB_CB_NORMALIZE_IMAGE
)

调用它通常只需要提供一幅图像以及棋盘的大小信息(水平和竖直的内角点的数量,如上图中分别是 6 和 4),该方法会返回这些棋盘角点在图像中的位置:

1
2
3
4
5
6
7
8
// output vectors of image points
std::vector<cv::Point2f> imageCorners;
// number of corners on the chessboard
cv::Size boardSize(6, 4);
// Get the chessboard corners
bool found = cv::findChessboardCorners(image,
boardSize,
imageCorners);

绘制检测得到的结点

OpenCV 也提供了一个函数用于将检测到的结点画到棋盘图像上:

1
2
3
4
// Draw the corners
cv::drawChessboardCorners(image,
boardSize, imageCorners,
found); // corners have been found

这些角点间的线条可以表示这些角点在 vector 中的顺序。

校正相机

当检测足够多不同角度的棋盘图像后(大概 10 ~ 20 张),就可以对相机进行校正了,OpenCV 提供了一个函数 cv::calibrateCamera() 进行校正:

1
2
3
4
5
6
7
8
9
10
11
12
double calibrateCamera(InputArrayOfArrays objectPoints,	// a vector of vectors of calibration pattern points 
InputArrayOfArrays imagePoints, // a vector of vectors of the projections of calibration pattern points.

Size imageSize, // size of the image
InputOutputArray cameraMatrix, // output 3x3 floating-point camera matrix
InputOutputArray distCoeffs, // output vector of distortion coefficients
OutputArrayOfArrays rvecs, // output vector of rotation vectors
OutputArrayOfArrays tvecs, // output vector of translation vectors e
int flags=0, // flags
TermCriteria criteria=TermCriteria( // termination criteria
TermCriteria::COUNT+TermCriteria::EPS,
30, DBL_EPSILON) )

调用该函数将可以得到两个重要的输出:相机矩阵 cameraMatrix 和畸变系数 distCoeffs

为了方便,可以将上面的所有步骤封装成一个类 CameraCalibrator

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
class CameraCalibrator {

// input points
std::vector<std::vector<cv::Point3f>> objectPoints;
std::vector<std::vector<cv::Point2f>> imagePoints;
// output Matrices
cv::Mat cameraMatrix;
cv::Mat distCoeffs;
// flag to specify how calibration is done
int flag;
// used in image undistortion
cv::Mat map1,map2;
bool mustInitUndistort;

public:
CameraCalibrator() : flag(0), mustInitUndistort(true) {};

// Open the chessboard images and extract corner points
int addChessboardPoints(const std::vector<std::string>& filelist, cv::Size & boardSize);
// Add scene points and corresponding image points
void addPoints(const std::vector<cv::Point2f>& imageCorners, const std::vector<cv::Point3f>& objectCorners);
// Calibrate the camera
double calibrate(cv::Size &imageSize);
// Set the calibration flag
void setCalibrationFlag(bool radial8CoeffEnabled=false, bool tangentialParamEnabled=false);
// Remove distortion in an image (after calibration)
cv::Mat remap(const cv::Mat &image);

// Getters
cv::Mat getCameraMatrix() { return cameraMatrix; }
cv::Mat getDistCoeffs() { return distCoeffs; }
};

其中:

  • addChessboardPoints() 函数 - 用于读入一系列的棋盘图像并检测角点;
  • calibrate() 函数 - 用于进行相机校正,得到相机的参数矩阵和畸变系数;
  • remap() 函数 - 用于根据相机校正结果修复图像的畸变;
  • addPoints() 函数 - addChessboardPoints() 在检测完角点后会调用这个函数。也可自己手动调用这个函数添加已知的角点位置和对应的空间坐标点。

使用示例:

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
int main()
{

cv::namedWindow("Image");
cv::Mat image;
std::vector<std::string> filelist;

// generate list of chessboard image filename
for (int i=1; i<=20; i++) {

std::stringstream str;
str << "../chessboards/chessboard" << std::setw(2) << std::setfill('0') << i << ".jpg";
std::cout << str.str() << std::endl;

filelist.push_back(str.str());
image= cv::imread(str.str(),0);
cv::imshow("Image",image);

cv::waitKey(100);
}

// Create calibrator object
CameraCalibrator cameraCalibrator;
// add the corners from the chessboard
cv::Size boardSize(6,4);
cameraCalibrator.addChessboardPoints(
filelist, // filenames of chessboard image
boardSize); // size of chessboard
// calibrate the camera
// cameraCalibrator.setCalibrationFlag(true,true);
cameraCalibrator.calibrate(image.size());

// Image Undistortion
image = cv::imread(filelist[6]);
cv::Mat uImage= cameraCalibrator.remap(image);

// display camera matrix
cv::Mat cameraMatrix= cameraCalibrator.getCameraMatrix();
std::cout << " Camera intrinsic: " << cameraMatrix.rows << "x" << cameraMatrix.cols << std::endl;
std::cout << cameraMatrix.at<double>(0,0) << " " << cameraMatrix.at<double>(0,1) << " " << cameraMatrix.at<double>(0,2) << std::endl;
std::cout << cameraMatrix.at<double>(1,0) << " " << cameraMatrix.at<double>(1,1) << " " << cameraMatrix.at<double>(1,2) << std::endl;
std::cout << cameraMatrix.at<double>(2,0) << " " << cameraMatrix.at<double>(2,1) << " " << cameraMatrix.at<double>(2,2) << std::endl;

imshow("Original Image", image);
imshow("Undistorted Image", uImage);

cv::waitKey();
return 0;
}

结果:

双目视觉

基本矩阵

背景知识

通过两个摄像头在不同的角度同时捕获同个场景,可以获得场景的空间信息,这个技术称为双目视觉。

考虑两个相机观察同一个场景点:

由图可以看出,我们可以通过直接连接一个三维空间点 X 和左侧相机的中心点来找到其在左侧相机的成像中的位置 x 。反过来考虑,一个成像点 x 所对应的实际三维空间点的位置一定在这条直线上。这意味着,如果我们需要找到点 x 在第二台相机的成像位置 I’,我们需要找到第一条线在第二个相机的投影位置,这条线称为点 x 的 核线 (epipolar line)。核线的角度取决于两个相机的相对位置。实际上,核线的位置与整个双目视觉系统的几何形状有关。

另一个可以观察得到的是所有核线都会经过一点 (图中的 e 或 e’),这个点是一个相机中心点在另一个相机的投影位置,这个特殊的点称为 核点 (epipole)。

从数学角度上看,一个成像点和其对应的核线可以使用如下的 3x3 的矩阵来表示:

\[\begin{bmatrix} l'_{1} \\ l'_{2} \\ l'_{3} \end{bmatrix} = F \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} \]

上面的公式表示一组 2D 点 \((x',y')\) 满足方程 \(l'_{1}x' + l'_{2}y' + l'_{3} = 0\) 。矩阵 F 称为 基本矩阵 (fundamental matrix),可以将一个视点的 2D 图像点映射到另一个视点的核线上。

计算方法

计算基本矩阵将是构建双目视觉系统的基础,方法是从两幅图像之间的若干已知配对点来计算一系列的方程。这些配对点可以使用特征匹配一节得到的配对结果。配对的最小数量是 7 。

OpenCV 提供了一个函数 cv::findFundamentalMat() 来计算基本矩阵:

1
2
3
4
5
6
Mat findFundamentalMat(InputArray points1,	// array of N points from the first image
InputArray points2, // array of N points from the second image.
int method=FM_RANSAC, // method for computing a fundamental matrix
double param1=3., // parameter used for RANSAC
double param2=0.99, // parameter used for RANSAC or LMedS methods only
OutputArray mask=noArray() )// output array of N elements

要使用这个函数,需要注意特征点的类型。cv::findFundamentalMat 接收的点的类型是 cv::Point2F ,因此对于通过特征匹配得到的 cv::Keypoint 类型的特征点,我们需要将它们转换成 cv::Point2f 类型:

1
2
3
4
// Convert keypoints into Point2f
std::vector<cv::Point2f> selPoints1, selPoints2;
cv::KeyPoint::convert(keypoints1, selPoints1, pointIndexes1);
cv::KeyPoint::convert(keypoints2, selPoints2, pointIndexes1);

两个 vector (selPoints1 和 selPoints2) 包含了两张图像中的配对点。之后调用 cv::findFundamentalMat() 函数如下:

1
2
3
4
5
// Compute F matrix from 7 matches
cv::Mat fundamental = cv::findFundamentalMat(
cv::Mat (selPoints1), // points in first image
cv::Mat (selPoints2), // points in second image
);

可以将核线画出来,以验证基本矩阵的正确性。 OpenCV 提供了一个函数 cv::computeCorrespondEpilines() 来根据一些输入点和基本矩阵得到核线,之后就可以使用 cv::line() 函数将这些核线画出。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// draw the left points corresponding epipolar
// lines in right image
std::vector<cv::Vec3f> lines1;
cv::computeCorrespondEpilines(
cv::Mat(selPoints1), // image points
1,
// in image 1 (can also be 2)
fundemental, // F matrix
lines1);
// vector of epipolar lines
// for all epipolar lines
for (vector<cv::Vec3f>::const_iterator it= lines1.begin();
it!=lines1.end(); ++it) {
// draw the line between first and last column
cv::line(image2,
cv::Point(0,-(*it)[2]/(*it)[1]),
cv::Point(image2.cols,-((*it)[2]+
(*it)[0]*image2.cols)/(*it)[1]),
cv::Scalar(255,255,255));
}

结果:

图像中,所有的核线都会经过一点,这个点如前面所述就是核点,它是另一个相机中心点的投影。有时候,核点会落在图像的外面。

双目图像匹配

要进行图像匹配,之前已经给出了若干基于特征匹配的方法。但对于双目系统,存在可靠的特征匹配方法:当在两幅图像间进行特征匹配时,只接受落在对应的核线中的配对点。不过,要进行这个条件判断,先得计算基本矩阵,而基本矩阵又是根据配对点估算出来的。这看起来是一个先有鸡还是先有蛋的问题。下面介绍一种基于 RANSAC(RANdom SAmpling Consensus) 算法的配对算法,可以一边计算基本矩阵,一边寻找一组较优的配对点:

  1. 检测特征点,并提取特征描述子;
  2. 使用K近邻算法配对特征点,先找出其第一幅图中每个特征点在第二幅图中最佳的两个配对,然后找出第二幅图中每个特征点在第一幅图中最佳的两个配对;
  3. NN 率测试:对于每个特征点,如果其两个最佳配对点的描述子和该特征点的描述子非常接近,则这选择任何一个配对都可能导致错误,两组都不够优,因此将两组配对都移除;
  4. 对称性测试:对于每个特征点,如果它的描述子和第一个配对点的描述子距离很小,而和第二个配对点的描述子距离很大,那么可以判定第一个配对点更优,因此将第二个配对移除;
  5. RANSAC测试:使用 RANSAC 算法来移除没有落在核线的特征点。RANSAC 是一种用来从一组包含 outliers 的数据中估算参数的迭代算法。在这里,我们每次随机采样 8 对配对点,计算其对应的基本矩阵并测试是否处于核线上,测试剩下的配对点构成了基本矩阵的支撑集。可想而知,如果一次随机取样的配对点包含错误,那么被删除的配对点就比较多,那么最后该支撑集就会比较小。迭代一定的次数后,我们从结果中挑出剩余配对点最多的,即最大的一个支撑集。

可以使用一个封装好的 RobustMatcher 类。其中:

  • match() 函数 - 核心匹配函数。返回匹配点、检测到的特征点和求得的基本矩阵;
  • ratioTest() 函数 - 用于 NN 率测试;
  • symmetryTest() 函数 - 用于对称性测试;
  • ransacTest() 函数 - 用于 RANSAC 测试。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Prepare the matcher
RobustMatcher rmatcher;
rmatcher.setConfidenceLevel(0.98);
rmatcher.setMinDistanceToEpipolar(1.0);
rmatcher.setRatio(0.65f);
cv::Ptr<cv::FeatureDetector> pfd=
new cv::SurfFeatureDetector(10);
rmatcher.setFeatureDetector(pfd);
// Match the two images
std::vector<cv::DMatch> matches;
std::vector<cv::KeyPoint> keypoints1, keypoints2;
cv::Mat fundemental= rmatcher.match(image1,image2,
matches, keypoints1, keypoints2);

对示例图片使用该算法后产生了 23 组配对,且这些都落在了核线上:

值得注意的是,该类默认使用 SURF 特征点,也可以很容易的通过 setFeatureDetector() 函数和 setDescriptorExtractor() 函数扩展其他类型的特征点:

1
2
3
4
5
6
7
8
9
10
// Set the feature detector
void setFeatureDetector(
cv::Ptr<cv::FeatureDetector>& detect) {
detector= detect;
}
// Set the descriptor extractor
void setDescriptorExtractor(
cv::Ptr<cv::DescriptorExtractor>& desc) {
extractor= desc;
}

单应矩阵

基本知识

如果一个视点只是另一个视点的旋转,那么此时基本矩阵是无定义的,取而代之的,可以使用单应(Homography)矩阵来进行图像配对。和基本矩阵类似,单应矩阵也是一种可以从一对图像计算得到的矩阵,其大小也是 3x3 。单应矩阵揭示了一个视点的成像中的一点与另一个视点成像中的一点满足如下线性关系:

\[\begin{bmatrix} sx'\\ sy' \\ s \end{bmatrix} = H \begin{bmatrix} x \\ y \\ l \end{bmatrix} \]

其中, \(H\) 是该单应矩阵,\(s\) 是尺度因子。若算出了该单应矩阵,那么一个视点的成像中的所有点将可以根据这个式子找到其在另一个视点的成像中的位置。要注意,单应矩阵只在这种特殊情况下才存在。

计算方法

OpenCV 提供了 cv::findHomography() 函数用来计算单应矩阵:

1
2
3
4
5
Mat findHomography(InputArray srcPoints,
InputArray dstPoints,
int method=0,
double ransacReprojThreshold=3,
OutputArray mask=noArray() )

在一个视点是另一个视点的旋转的情况下,同样可以使用前面提及的 RobustMatcher 类来进行图像配对,唯一的不同是在 RANSAC 测试中,计算基本矩阵改成了计算单应矩阵:

1
2
3
4
5
6
7
8
// Find the homography between image 1 and image 2
std::vector<uchar> inliers(points1.size(),0);
cv::Mat homography= cv::findHomography(
cv::Mat(points1), // corresponding
cv::Mat(points2), // points
inliers, // outputted inliers matches
CV_RANSAC, // RANSAC method
1.); // max distance to reprojection point

得到的匹配点:

一旦得到了单应矩阵,我们可以很容易的确定图像中的一点在另一张图像中的位置。根据这个对应关系,我们可以把一张图从原来的视角变形到另一张图的视角:

1
2
3
4
5
6
7
// Warp image 1 to image 2
cv::Mat result;
cv::warpPerspective(image1, // input image
result, // output image
homography, // homography
cv::Size(2*image1.cols,
image1.rows)); // size of output image

值得注意的是,默认得到的单应矩阵如果用来将第一张图的视角变到第二张图的视角,应该使用这个单应矩阵的逆矩阵。因此,cv::warpPerspective() 函数默认会先计算传入的单应矩阵的逆矩阵,然后用这个逆矩阵来变形。如果传入的矩阵已经是单应矩阵的逆矩阵,则可以在调用时指定 flag 参数为 cv::WARP_INVERSE_MAP

之后,可以把另一张图和这张已经处在同一视点的图拼接在一起:

1
2
3
// Copy image 1 on the first half of full image
cv::Mat half(result, cv::Rect(0, 0, image2.cols, image2.rows));
image2.copyTo(half); // copy image2 to image1 roi

结果:


  1. 这里用“稳定”这个形容词是指统计意义上的稳定,因为 mean-shift 忽略了数据中的 outliers ,即忽略远离数据峰值的点。mean-shift仅对数据局部窗口中的点进行处理,处理完成后再移动窗口。 ↩︎

Comments