双目视觉标定就是通过求解实际三维空间中坐标点和摄像机二维图像坐标点的对应关系,在双目视觉中,三维空间坐标系一般是以左相机坐标系作为基准坐标系。利用棋盘板获取到的用于计算的二维图像坐标和三维空间的物理坐标,再通过一定的算法,求解出变换矩阵,则解决了基础的双目视觉标定的过程。
实际标定过程中需要考虑镜头的畸变:包括径向畸变及切向畸变。因为切向畸变很小,所以通常主要考虑的就是径向畸变。求解畸变的方法后面再讨论。
下面结合OpenCV自带双目标定的例程来学习掌握下双目视觉的标定过程:
a. 输入准备:14(数字可调)对棋盘图、标定板的尺寸(widthxheight格数)及棋盘格物理尺寸、stereo_calib.xml(输入图片的列表)
b. 立体标定。主要可分为4个部分:
输入检测与变量初始化
角点存储矩阵
vector > imagePoints[2];//这个是左右图像中的二维点 vector > objectPoints;//由上面的二维点得到的三维点
角点及亚像素角点检测,获取角点的2D图像坐标和3D物理坐标
对左右相机的每一幅图像单独处理。要注意处理的图像必须含有相同的大小。此外,为了准确的检测出角点,部分图像可能需要进行scale缩放调整。
findChessboardCorners()函数用于检测角点,需要输入目标图像,标定板大小,角点存储矩阵,及最后的算法设置变量,例程中为CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE表示使用直方图均衡算法和自适应二值化的法。返回bool值。具体的函数使用见
棋盘格角点检测与绘制——cv::findChessboardCorners()与cv::drawChessboardCorners()详解
cornerSubPix()函数根据已经得到的corners坐标计算更加精确的亚像素corners坐标。该函数需要输入目标图像,corners坐标矩阵,搜索窗口大小(Size()类型),死区窗口大小(Size()类型,目的是避免可能出现的自相关矩阵的奇点,不明白这一参数,可以设为默认值Size(-1,-1)),终止迭代的criteria,本文中为TermCriteria(TermCriteria::COUNT+TermCriteria::EPS,30, 0.01),表示当迭代次数大于30或当角点坐标变化小于0.01时停止。
通过上述三个函数的使用,便可以准确的检测出所有图像的亚像素角点。
然后根据输入的标定板大小和棋盘格格子的物理长度大小,便可以计算出角点的物理坐标,单位毫米。标定板的空间坐标系假设是以左上角第一个点为原点,棋盘格两边为x,y方向,垂直棋盘格为z方向。所以所有角点的空间坐标的z值均为0。
- 双目标定主模块,计算内参数矩阵,对标定结果进行验证
stereoCalibrate()函数计算双目标定中的内参数矩阵,相对于1st相机的旋转平移矩阵,还有本征矩阵E和基础矩阵F。E包含在物理空间中两个摄像机相关的旋转和平移信息,F除了包含E的信息外还包括了两个摄像机的内参数。E是将左摄像机观测到的点P的物理坐标和右摄像机观察到的相同点的位置关联起来。F是将一台摄像机的像平面的点在像平面上的坐标和另一台摄像机的像平面上的点关联起来。
关于本征矩阵和基础矩阵的详细介绍参考博客本征矩阵与基础矩阵。
立体标定完成后,通过对极几何约束公式m2TFm1可以检查校准的质量(检查图像上点与另一幅图像的极线的距离的远近来评定标定的精度),理想情况下点和线的点积为0,累计后的绝对距离形成了误差。可用于衡量标定结果。
(这块不是很理解。。。。)
对标定后的结果进行立体校正,计算外参数矩阵
利用stereoRectify()函数进行立体校正,这样做的目的是为了使得两个相机的光轴共面,极线平行。这样,就可以将二维的图像搜索简化为一维的图像检索,同时确保了极线匹配的精度。
立体标定结束后,将相应的参数写入intrinsics.yml和extrinsics.yml中,则完成了双目视觉的标定。即双目标定最后获得的是相机内参和外参变换的文件。
include “stdafx.h”
include “opencv2/calib3d.hpp”
include “opencv2/imgcodecs.hpp”
include “opencv2/highgui.hpp”
include “opencv2/imgproc.hpp”
include include include include include include include include
using namespace cv;
using namespace std;
//左图中的同一目标比右图中的同一目标偏右,即左右图命名不要弄反,与人眼视觉一致。
static void
StereoCalib(const vector& imagelist, Size boardSize, float squareSize, bool displayCorners = true, bool useCalibrated=true, bool showRectified=true)
//立体标定主程序:输入图片列表、棋盘图大小、面积大小、等控制标记
{
//1. 输入检测及变量初始化
if( imagelist.size() % 2 != 0 )//判断标定图片成对
{
cout << “Error: the image list contains odd (non-even) number of elements\n”;
return;
}
const int maxScale = 2;
const float squareSize = 26.f; //设置真实方格大小,1以毫米或者像素为单位的keypoint之间间隔距离,棋盘间隔1
// ARRAY AND VECTOR STORAGE: //数组储存
vector<vector<Point2f> > imagePoints[2];//这个是左右图像中的二维点
vector<vector<Point3f> > objectPoints;//由上面的二维点得到的三维点
Size imageSize;//图像大小
int i, j, k, nimages = (int)imagelist.size()/2;//nimages是棋盘图对数,j是用于记录最后检测到了多少对棋盘图
imagePoints[0].resize(nimages); //设置向量大小
imagePoints[1].resize(nimages);
vector<string> goodImageList;//检测到的棋盘图像列表(因为有的棋盘图像是检测不到的)
// 2.角点及亚像素角点检测,获取角点的2D图像坐标和3D物理坐标
for( i = j = 0; i < nimages; i++ ) //单相机0-13幅图
{
for( k = 0; k < 2; k++ )//左右相机
{
const string& filename = imagelist[i*2+k];//图像文件名
Mat img = imread(filename, 0);
if(img.empty())
break;
if( imageSize == Size() )
imageSize = img.size();
else if( img.size() != imageSize )
{
cout << "The image " << filename << " has the size different from the first image size. Skipping the pair\n";
break;
}
bool found = false;
vector<Point2f>& corners = imagePoints[k][j];//左右图的第j幅图像的所有角点,通过findChessboardCorners对向量传参
//寻找角点,保存到imagePoints
for( int scale = 1; scale <= maxScale; scale++ )
//通过scale是防止检测不到
{
Mat timg;
if( scale == 1 )
timg = img;
else
resize(img, timg, Size(), scale, scale, INTER_LINEAR_EXACT);
found = findChessboardCorners(timg, boardSize, corners,
CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE);//如果找到角点就返回true
if( found )
{
if( scale > 1 )
{
Mat cornersMat(corners);//vector角点转化为Mat矩阵,方便计算
cornersMat *= 1./scale;//scale放大了,所以要缩放
}
break;
}
}
if( displayCorners )
{
cout << filename << endl;
Mat cimg, cimg1;
cvtColor(img, cimg, COLOR_GRAY2BGR);
drawChessboardCorners(cimg, boardSize, corners, found);//棋盘格图像(8UC3)既是输入也是输出
double sf = 640./MAX(img.rows, img.cols);
resize(cimg, cimg1, Size(), sf, sf, INTER_LINEAR_EXACT);
imshow("corners", cimg1);
char c = (char)waitKey(500);
if( c == 27 || c == 'q' || c == 'Q' ) //Allow ESC to quit
exit(-1);
}
else
putchar('.');
if( !found )
break;
//插值亚像素点,用来精确得到的corners坐标
cornerSubPix(img, corners,
Size(11,11), //为搜索窗口大小,区域大小为NxN,N = (winSize*2 + 1),搜索窗口边长的一半
Size(-1,-1),//Size(-1,-1)表示忽略,当值为(-1,-1)表示没有死区
TermCriteria(TermCriteria::COUNT+TermCriteria::EPS,
30, 0.01)//停止优化的标准,当迭代次数大于30或当角点坐标变化小于0.01时停止
);
}//k循环结束,k = 2
if( k == 2 )//内层循环完后将j加 1 ,并把找到的棋盘图放入容器
{
goodImageList.push_back(imagelist[i*2]);
goodImageList.push_back(imagelist[i*2+1]);
j++;
}
}
cout << j << " pairs have been successfully detected.\n";
nimages = j;
if( nimages < 2 )//如果图片数量少于一对,那么报错
{
cout << "Error: too little pairs to run the calibration\n";
return;
}
imagePoints[0].resize(nimages);
imagePoints[1].resize(nimages);
objectPoints.resize(nimages);
//计算角点的3D物理坐标
for( i = 0; i < nimages; i++ )
{
for( j = 0; j < boardSize.height; j++ )
for( k = 0; k < boardSize.width; k++ )
objectPoints[i].push_back(Point3f(k*squareSize, j*squareSize, 0));//通过角点长宽以及squareSize每个角点的步长算出角点的位置
}
cout << "Running stereo calibration ...\n";
//3. 双目标定主模块,计算内参数矩阵,对标定结果进行验证
Mat cameraMatrix[2], distCoeffs[2];
cameraMatrix[0] = initCameraMatrix2D(objectPoints,imagePoints[0],imageSize,0);//定义3D到2D的初始化的摄像机变换矩阵
cameraMatrix[1] = initCameraMatrix2D(objectPoints,imagePoints[1],imageSize,0);
Mat R, T, E, F;//R 第一与第二相机坐标系之间的旋转矩阵
//T 第一与第二相机坐标系之间的旋转矩阵平移向量
//E 本征矩阵
//F 基础矩阵
double rms = stereoCalibrate(objectPoints, imagePoints[0], imagePoints[1],
cameraMatrix[0], distCoeffs[0],
cameraMatrix[1], distCoeffs[1],
imageSize, R, T, E, F,
CALIB_FIX_ASPECT_RATIO + //优化,确定的比值
CALIB_ZERO_TANGENT_DIST + //设置每个相机切向畸变系数为0且设为固定值
CALIB_USE_INTRINSIC_GUESS + //内参初始值可以设定
CALIB_SAME_FOCAL_LENGTH + //强制横纵方向焦距相同
CALIB_RATIONAL_MODEL + //启用参数k4,k5,k6。提供向后兼容性,这额外FLAG应该明确指定校正函数和返回8个系数。如果FLAG没有被设置,该函数计算并只返回5畸变系数。
CALIB_FIX_K3 + CALIB_FIX_K4 + CALIB_FIX_K5,//计算极线向量
TermCriteria(TermCriteria::COUNT+TermCriteria::EPS, 100, 1e-5)//迭代优化算法终止的标准(用来判断校准效果)
);
cout << "done with RMS error=" << rms << endl;
// CALIBRATION QUALITY CHECK
// because the output fundamental matrix implicitly
// includes all the output information,
// we can check the quality of calibration using the
// epipolar geometry constraint: m2^t*F*m1=0
/* 验证标定的效果:由于输出的基础矩阵包含所有的输出信息,所以这里可以用对极几何约束(m2^t*F*m1 = 0)来验证---- - 下面这段程序可以不要*/
/*
校准详细过程:
检查图像上点与另一幅图像的极线的距离的远近来评价标定的精度。
使用undistortPoints对原始点做去畸变处理。
使用computeCorrespondEpilines来计算极线。
然后,计算这些点和线的点积(理想情况,这些点积都为0)。
累计的绝对距离形成了误差。
*/
double err = 0;
int npoints = 0;
vector<Vec3f> lines[2];//极线
for( i = 0; i < nimages; i++ )
{
int npt = (int)imagePoints[0][i].size();//左相机图中所有角点数量
Mat imgpt[2];
for( k = 0; k < 2; k++ )
{
imgpt[k] = Mat(imagePoints[k][i]);//第i幅图的角点向量矩阵
undistortPoints(imgpt[k], imgpt[k], cameraMatrix[k], distCoeffs[k], Mat(), cameraMatrix[k]);//计算校正后的角点坐标
//计算对应点的外极线epilines是一个三元组(a,b,c),表示点在另一视图中对应的外极线ax+by+c=0;
computeCorrespondEpilines(imgpt[k], k+1, F, lines[k]);//为一幅图像中的点计算其在另一幅图中对应的对极线
}
for( j = 0; j < npt; j++ )
{
double errij = fabs(imagePoints[0][i][j].x*lines[1][j][0] +
imagePoints[0][i][j].y*lines[1][j][1] + lines[1][j][2]) +
fabs(imagePoints[1][i][j].x*lines[0][j][0] +
imagePoints[1][i][j].y*lines[0][j][1] + lines[0][j][2]);
err += errij;
}
npoints += npt;
}
cout << "average epipolar err = " << err/npoints << endl;
// save intrinsic parameters
FileStorage fs("intrinsics.yml", FileStorage::WRITE); //创建.yml文件
if( fs.isOpened() )
{
fs << "M1" << cameraMatrix[0] << "D1" << distCoeffs[0] <<
"M2" << cameraMatrix[1] << "D2" << distCoeffs[1];
fs.release();
}
else
cout << "Error: can not save the intrinsic parameters\n";
//4.对标定后的结果进行立体校正,计算外参数矩阵
//stereoRectify根据内参和畸变系数计算右相机相对左相机的旋转R和平移矩阵T
//并将旋转与平移矩阵分解为左右相机个旋转一般的旋转矩阵R1,R2和平移矩阵T1,T2
//这里用的是bougust极线校准方法
Mat R1, R2, P1, P2, Q;//R1,R2两个相机的3x3旋转矩阵
//P1,P2在第一/二台相机的矫正后的坐标系下的3x4投影矩阵
//Q 深度视差映射矩阵
Rect validRoi[2];
stereoRectify(cameraMatrix[0], distCoeffs[0],
cameraMatrix[1], distCoeffs[1],
imageSize, R, T, R1, R2, P1, P2, Q,
CALIB_ZERO_DISPARITY, 1, imageSize, &validRoi[0], &validRoi[1]);//立体校正程序,适用标定过的摄像机
fs.open("extrinsics.yml", FileStorage::WRITE);
if( fs.isOpened() )//在.yml中写入矩阵参数
{
fs << "R" << R << "T" << T << "R1" << R1 << "R2" << R2 << "P1" << P1 << "P2" << P2 << "Q" << Q;
fs.release();
}
else
cout << "Error: can not save the extrinsic parameters\n";
// OpenCV 可以处理左右放置和上下放置的相机
bool isVerticalStereo = fabs(P2.at<double>(1, 3)) > fabs(P2.at<double>(0, 3));
// 校正映射
if( !showRectified )
return;
Mat rmap[2][2];//校正映射:左右图像各两个
// IF BY CALIBRATED (BOUGUET’S METHOD)
if( useCalibrated )
{
// we already computed everything
}
// 否则使用HARTLEY’S METHOD校正
else
//使用每个相机的内部参数,但校正变换直接通过基础矩阵的计算得到
{
vector allimgpt[2];//拷贝的角点
for( k = 0; k < 2; k++ )
{
for( i = 0; i < nimages; i++ )
std::copy(imagePoints[k][i].begin(), imagePoints[k][i].end(), back_inserter(allimgpt[k]));
}
F = findFundamentalMat(Mat(allimgpt[0]), Mat(allimgpt[1]), FM_8POINT, 0, 0);//计算基础矩阵
Mat H1, H2;//计算单应矩阵
stereoRectifyUncalibrated(Mat(allimgpt[0]), Mat(allimgpt[1]), F, imageSize, H1, H2, 3);
R1 = cameraMatrix[0].inv()*H1*cameraMatrix[0];
R2 = cameraMatrix[1].inv()*H2*cameraMatrix[1];
P1 = cameraMatrix[0];
P2 = cameraMatrix[1];
}
//显示校正后的图像
//计算左右视图的校正查找映射表
initUndistortRectifyMap(cameraMatrix[0], distCoeffs[0], R1, P1, imageSize, CV_16SC2, rmap[0][0], rmap[0][1]);
initUndistortRectifyMap(cameraMatrix[1], distCoeffs[1], R2, P2, imageSize, CV_16SC2, rmap[1][0], rmap[1][1]);
Mat canvas; // 这个图像的长是棋盘图的两倍,高和棋盘图像一样(这是在水平放置的情况下)
double sf;
int w, h;
if( !isVerticalStereo) //这个是水平放置的
{
sf = 600./MAX(imageSize.width, imageSize.height);
w = cvRound(imageSize.width*sf);
h = cvRound(imageSize.height*sf);
canvas.create(h, w*2, CV_8UC3);
}
else
{
sf = 300./MAX(imageSize.width, imageSize.height);
w = cvRound(imageSize.width*sf);
h = cvRound(imageSize.height*sf);
canvas.create(h*2, w, CV_8UC3);
}
/*这里是画出校正后的棋盘图:对棋盘图进行校正、画出校正后的可用ROI、画出左右两边对极后的极线。如果执行这里,则要等这里检测到的所有棋盘图都画完之后才会执行后续操作*/
for( i = 0; i < nimages; i++ )
{
for( k = 0; k < 2; k++ )
{
Mat img = imread(goodImageList[i*2+k], 0), rimg, cimg;
remap(img, rimg, rmap[k][0], rmap[k][1], INTER_LINEAR);//对图像原进行重映射,映射输出为校正后的图像
cvtColor(rimg, cimg, COLOR_GRAY2BGR);
Mat canvasPart = !isVerticalStereo ? canvas(Rect(w*k, 0, w, h)) : canvas(Rect(0, h*k, w, h));//这里是得出校正两个ROI
resize(cimg, canvasPart, canvasPart.size(), 0, 0, INTER_AREA);
if( useCalibrated )
{
Rect vroi(cvRound(validRoi[k].x*sf), cvRound(validRoi[k].y*sf),
cvRound(validRoi[k].width*sf), cvRound(validRoi[k].height*sf));
rectangle(canvasPart, vroi, Scalar(0,0,255), 3, 8);//将矩形画出来
}
}
if( !isVerticalStereo )
for( j = 0; j < canvas.rows; j += 16 ) //画极线
line(canvas, Point(0, j), Point(canvas.cols, j), Scalar(0, 255, 0), 1, 8);
else
for( j = 0; j < canvas.cols; j += 16 )
line(canvas, Point(j, 0), Point(j, canvas.rows), Scalar(0, 255, 0), 1, 8);
imshow("rectified", canvas);
char c = (char)waitKey();
if( c == 27 || c == 'q' || c == 'Q' )
break;
}
}
static bool readStringList( const string& filename, vector& l )//现在这里是读取每个图像文件名,把图像名放到imagelist 容器中
{
l.resize(0);
FileStorage fs(filename, FileStorage::READ);
if( !fs.isOpened() )
return false;
FileNode n = fs.getFirstTopLevelNode();//返回映射(mapping)顶层的第一个元素,及.xml文件第一个元素
if( n.type() != FileNode::SEQ )
return false;
FileNodeIterator it = n.begin(), it_end = n.end();
for( ; it != it_end; ++it )
l.push_back((string)*it);//这里是把读取到的图像的名字放入容器
return true;
}
int main(int argc, char** argv)
{
Size boardSize;//标定板尺寸
string imagelistfn;
bool showRectified = true;
cv::CommandLineParser parser(argc, argv, “{w|9|}{h|6|}{s|1.0|}{nr||}{help||}{@input|E://Visual Studio 2015//Projects//refreCode1//standSample//stereo_calib.xml|}”);
if (parser.has(“help”))
return -1;
showRectified = !parser.has(“nr”);
imagelistfn = parser.get(“@input”);
boardSize.width = parser.get(“w”);
boardSize.height = parser.get(“h”);
float squareSize = parser.get(“s”);
if (!parser.check())
{
parser.printErrors();
system(“pause”);
return 1;
}
vector imagelist;
bool ok = readStringList(imagelistfn, imagelist);
if(!ok || imagelist.empty())
{
cout << “can not open “ << imagelistfn << “ or the string list is empty” << endl;
return -1;
}
StereoCalib(imagelist, boardSize, squareSize, false, true, showRectified);
system("pause");
return 0;
}