最近想深入学习OpenCV,于是打算翻译部分官方文档。由于是学生,水平有限,有些错误在所难免,望读者指正。
原文地址
相机诞生很久了。然而,直到20世纪后期廉价的针孔相机问世,相机才逐渐地走向千家万户。问题在于廉价也是有代价的:(图像)畸变严重。不过好处在于这些畸变是固定的,并且通过标定和一些重绘我们可以克服这个问题。除此之外,你可以通过标定得到相机原始单位(像素)与现实中的物理单位的关系。
OpenCV考虑畸变的径向和切向因子(radial and tangential factors)。对于径向因子,采用以下公式来矫正:
所以对于输入图像中在(x,y)处的像素点,它的对应(相机坐标中的)位置便是是(Xcorrected, Ycorrected)。径向畸变的体现是常说的“桶状”(”barrel”)或“鱼眼”(“fish-eye”)效应。
切向畸变是由于摄像头的镜头与图像平面不是完全平行而导致的。它可以通过以下公式来矫正:
所以我们在OpenCV里有五个畸变参数,它们被用一个5列的行向量表示:
现在,为了单位转换的方便,我们使用以下公式:
这里w的存在是因为使用了齐次坐标系(并且w=z)。未知的参数是fx和fy(摄像头焦距)以及cx,cy,是摄像头光学中点在像素平面的位置。如果对于两个方向上的焦距我们用一个系数a(通常是1)联系起来,那么就有 fy = fx * a 并且在上面的公式里就可以将fx,fy用f来表示。包含这四个参数的矩阵一般称之为相机矩阵。尽管畸变系数不随着相机分辨率的改变而变化,但是它会随当前分辨率到矫正后的分辨率的改变而变化。
得出上述两个矩阵的过程我们称之为标定。通过基本解几何方程我们能够得出这些参数。这些方程长什么样主要取决于我们使用的标定物体。目前OpenCV支持三种标定物体: * 标准黑白棋盘 * 对称圆形图案 * 非对称圆形图案
通常,你需要用你的相机对这些标定图案拍一些照片,并且通过OpenCV来找到这些图案。每个找到的图案会产生一个新的方程。想解这个方程你就起码要拍摄指定数量以上的照片来生成一个适定的方程组。对于棋盘图案来说这个数字比较大,但是对于圆形图案这个数字比较小。举个例子,理论上棋盘图案至少需要两张照片。但是实际操作中我们的输入图像中会有大量的噪音,所以为了获得较好的效果你最好从不同的位置上拍摄10张以上的图像。
下面这个例子将会: * 确定畸变矩阵 * 确定相机矩阵 * 从相机,视频和图像文件列表中获得输入图像 * 从XML/YAML文件中读取设置 * 保存结果到XML/YAML文件中去 * 计算重投影偏差
你也可以从OpenCV源文件夹中的 samples/cpp/tutorial_code/calib3d/camera_calibration/ 文件夹中找到源代码或者从这下载。这个程序有一个输入参数:设置文件的名字。如果没有给出这个参数,这个程序会试图打开一个名为”default.xml”的文件。这里有一个用XML格式写的示例设置文件。在设置文件里你可以选择输入图像的来源是相机,视频文件或者图像列表。如果你选择了图像列表,你可能需要在设置列表里列出所需要的图片文件位置。这里有个示例文件。需要记住一点,图像要给出全路径或者相对于这个程序的相对路径。你可以在sample目录下看到我们上述提到的那些。
这个程序从读取设置文件开始。尽管这很重要,但是这和我们今天的主题——相机标定没关系,所以我不打算把这段代码贴上来。如何做到读取文件你可以看看这篇文章XML和YAML文件的读写。
读取设置
Settings s;
const string inputSettingsFile = argc > 1 ? argv[1] : "default.xml";
FileStorage fs(inputSettingsFile, FileStorage::READ); // 读取设置
if (!fs.isOpened())
{
cout << "Could not open the configuration file: \"" << inputSettingsFile << "\"" << endl;
return -1;
}
fs["Settings"] >> s;
fs.release(); // 关闭设置文件
if (!s.goodInput)
{
cout << "Invalid input detected. Application stopping. " << endl;
return -1;
}
我在这用了简单的OpenCV类输入操作。在读取文件之后,我又增加了后处理(post-processing)函数来检查输入是否有效。只有当所有输入都有效,goodInput变量才会是true。
- 获得下一个输入,如果失败了或者我们有了足够多的输入-标定。这之后我们有一个大循环,在这循环里我们进行:从图片列表,相机或者视频里获得下一张图片。如果失败或者输入足够,我们就开始进行标定过程。如果我们从图像列表里读取的图片,我们会直接跳出循环,否则(视频或相机)剩下的帧便不会失真(通过将模式从DETECTION设置为CALIBRATED)。
for(int i = 0;;++i)
{
Mat view;
bool blinkOutput = false;
view = s.nextImage();
//----- 如果没有足够的图片或者已经取得足够图片则停止标定并且展示结果 -------------
if( mode == CAPTURING && imagePoints.size() >= (unsigned)s.nrFrames )
{
if( runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints))
mode = CALIBRATED;
else
mode = DETECTION;
}
if(view.empty()) // If no more images then run calibration, save and stop loop.
{
if( imagePoints.size() > 0 )
runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints);
break;
imageSize = view.size(); // Format input image.
if( s.flipVertical ) flip( view, view, 0 );
}
对于一些相机我们需要翻转输入图像,这里我们也这么做了。
- 在当前输入中找到标定图案。上面我们提到的方程目的是找到输入的主要图案:对于棋盘他们是正方形的角点,对于圆形图案要找的就是圆形本身。这些图案的位置我们将会存入pointBuf容器中。
vector<Point2f> pointBuf;
bool found;
switch( s.calibrationPattern ) // Find feature points on the input format
{
case Settings::CHESSBOARD:
found = findChessboardCorners( view, s.boardSize, pointBuf,
CV_CALIB_CB_ADAPTIVE_THRESH | CV_CALIB_CB_FAST_CHECK | CV_CALIB_CB_NORMALIZE_IMAGE);
break;
case Settings::CIRCLES_GRID:
found = findCirclesGrid( view, s.boardSize, pointBuf );
break;
case Settings::ASYMMETRIC_CIRCLES_GRID:
found = findCirclesGrid( view, s.boardSize, pointBuf, CALIB_CB_ASYMMETRIC_GRID );
break;
}
依据输入图案的类型使用finChessboardCorners或者findCirclesGrid函数。这两个函数你都要传入当前图像和标定板的尺寸,然后你就能得到图案的位置。并且,它们还会返回一个布尔变量来表明是否图案被找到(所以我们只要考虑这个值为true的输入图像就好了)。 在输入图像来源是相机的情况下,我们只会在输入的延时之后才会拍摄。这么做是为了允许使用者四处移动棋盘来得到不同的图像。相似的图像会导致相似的方程,而相似的方程在标定步骤时会产生不适定问题,这会导致标定失败。对于棋盘来说角点的坐标只是近似的。想提高精度我们可以使用函数cornerSubPix。这个函数能优化标定的结果。在这之后我们增加一个有效输入的结果进入imagePoints容器里以将所有的方程放进一个容器。最后,为了得到可视化的反馈结果我们会画出输入图像使用findChessboardCorners所找到的角点。
if ( found) // 如果找到了,
{
// 提高棋盘找到的角点的精度
if( s.calibrationPattern == Settings::CHESSBOARD)
{
Mat viewGray;
cvtColor(view, viewGray, CV_BGR2GRAY);
cornerSubPix( viewGray, pointBuf, Size(11,11),
Size(-1,-1), TermCriteria( CV_TERMCRIT_EPS+CV_TERMCRIT_ITER, 30, 0.1 ));
}
if( mode == CAPTURING && // 对相机,只有在延时之后才会拍摄新的图像
(!s.inputCapture.isOpened() || clock() - prevTimestamp > s.delay*1e-3*CLOCKS_PER_SEC) )
{
imagePoints.push_back(pointBuf);
prevTimestamp = clock();
blinkOutput = s.inputCapture.isOpened();
}
// 画出这些角点。
drawChessboardCorners( view, s.boardSize, Mat(pointBuf), found );
}
- 给用户展示状态与结果,为这个程序增加命令行控制。这部分在图像上显示了文字输出结果。
//----------------------------- 输出文字 ------------------------------------------------
string msg = (mode == CAPTURING) ? "100/100" :
mode == CALIBRATED ? "Calibrated" : "Press 'g' to start";
int baseLine = 0;
Size textSize = getTextSize(msg, 1, 1, 1, &baseLine);
Point textOrigin(view.cols - 2*textSize.width - 10, view.rows - 2*baseLine - 10);
if( mode == CAPTURING )
{
if(s.showUndistorsed)
msg = format( "%d/%d Undist", (int)imagePoints.size(), s.nrFrames );
else
msg = format( "%d/%d", (int)imagePoints.size(), s.nrFrames );
}
putText( view, msg, textOrigin, 1, 1, mode == CALIBRATED ? GREEN : RED);
if( blinkOutput )
bitwise_not(view, view);
如果我们已经进行了标定并且得到了有畸变系数的相机矩阵,我们也许想去通过undistort函数来矫正图像:
//------------------------- Video capture output undistorted ------------------------------
if( mode == CALIBRATED && s.showUndistorsed )
{
Mat temp = view.clone();
undistort(temp, view, cameraMatrix, distCoeffs);
}
//------------------------------ 显示图片并且检查输入的命令 -------------------
imshow("Image View", view);
然后我们等待一个输入的按键。并且如果是u我们反转是否显示畸变矫正图像的设置,如果是’g’我们再次开始检测的过程,最后如果是ESC键我们退出这个程序。
char key = waitKey(s.inputCapture.isOpened() ? 50 : s.delay);
if( key == ESC_KEY )
break;
if( key == 'u' && mode == CALIBRATED )
s.showUndistorsed = !s.showUndistorsed;
if( s.inputCapture.isOpened() && key == 'g' )
{
mode = CAPTURING;
imagePoints.clear();
}
- 显示畸变矫正后的图像。 当你处理图像列表时,你不可能在循环内部消除畸变。因此,你只有在循环结束后做这件事情。为了利用这点我现在打算展开讲undistort函数,这个函数先调用initUndistortRectifyMap来得到变换矩阵,然后通过remap函数来实施这些变换。因为在标定成功后map计算只需要进行一次,所以你可以通过这一点来使你的程序加速。
if( s.inputType == Settings::IMAGE_LIST && s.showUndistorsed )
{
Mat view, rview, map1, map2;
initUndistortRectifyMap(cameraMatrix, distCoeffs, Mat(),
getOptimalNewCameraMatrix(cameraMatrix, distCoeffs, imageSize, 1, imageSize, 0),
imageSize, CV_16SC2, map1, map2);
for(int i = 0; i < (int)s.imageList.size(); i++ )
{
view = imread(s.imageList[i], 1);
if(view.empty())
continue;
remap(view, rview, map1, map2, INTER_LINEAR);
imshow("Image View", rview);
char c = waitKey();
if( c == ESC_KEY || c == 'q' || c == 'Q' )
break;
}
}
标定与保存
因为对于每个相机来说标定只需要执行一次,所以在成功标定之后保存标定结果是很有意义的。这样,之后你就可以把这些值读入你的程序中。因此我们首先进行标定,然后,如果成功的话我们就把这些结果保存成OpenCV样式的XML和YAML文件,保存成哪个取决你在设置文件里的设置。 因此,在第一个函数里我们会将其分为两个过程。因为我们想保存许多标定变量,我们首先创建这些变量,然后将他们传递给标定和保存函数。同样的,我不会展示保存的部分,因为它和标定的部分有点一样。你可以自己去看源码文件来明白这是怎么做的:
bool runCalibrationAndSave(Settings& s, Size imageSize, Mat& cameraMatrix, Mat& distCoeffs,vector<vector<Point2f> > imagePoints )
{
vector<Mat> rvecs, tvecs;
vector<float> reprojErrs;
double totalAvgErr = 0;
bool ok = runCalibration(s,imageSize, cameraMatrix, distCoeffs, imagePoints, rvecs, tvecs,
reprojErrs, totalAvgErr);
cout << (ok ? "Calibration succeeded" : "Calibration failed")
<< ". avg re projection error = " << totalAvgErr ;
if( ok ) // save only if the calibration was done with success
saveCameraParams( s, imageSize, cameraMatrix, distCoeffs, rvecs ,tvecs, reprojErrs,
imagePoints, totalAvgErr);
return ok;
}
我们通过calibrateCamera函数来标定。这个函数有以下几个参数: * 物体的点集。 这是对于每幅输入图像关于Point3f的容器,它描述了这些图案看上去长啥样。如果我们用的是平面的图案(比如说棋盘),那我们可以简单的将Z坐标设为0。这是那些重要的点的点集,因为对于所有的输入图案我们使用的是同一幅输入图像,所以我们可以只计算一次这个值然后通过对它做乘法来使其适合其他输入图像。我们通过calcBoardCornerPositions来计算角点坐标:
void calcBoardCornerPositions(Size boardSize, float squareSize, vector<Point3f>& corners,
Settings::Pattern patternType /*= Settings::CHESSBOARD*/)
{
corners.clear();
switch(patternType)
{
case Settings::CHESSBOARD:
case Settings::CIRCLES_GRID:
for( int i = 0; i < boardSize.height; ++i )
for( int j = 0; j < boardSize.width; ++j )
corners.push_back(Point3f(float( j*squareSize ), float( i*squareSize ), 0));
break;
case Settings::ASYMMETRIC_CIRCLES_GRID:
for( int i = 0; i < boardSize.height; i++ )
for( int j = 0; j < boardSize.width; j++ )
corners.push_back(Point3f(float((2*j + i % 2)*squareSize), float(i*squareSize), 0));
break;
}
}
然后对其做乘法:
vector<vector<Point3f> > objectPoints(1);
calcBoardCornerPositions(s.boardSize, s.squareSize, objectPoints[0], s.calibrationPattern);
objectPoints.resize(imagePoints.size(),objectPoints[0]);
- 图像的点集。 这是一个关于Point2f对象的容器,这些点是每幅输入图像中的重要的点集(对于棋盘是角点,对于圆形图案,是圆形的中点)。我们已经通过findChessboardCorners或者findCirclesGrid函数来得到了这些点。我们只需要将其传入就好了。
- 从图像列表,相机或视频里获取的图像的尺寸。
- 相机矩阵。 如果我们选择固定长宽比选项,我们需要将fx设为0:
cameraMatrix = Mat::eye(3, 3, CV_64F);
if( s.flag & CV_CALIB_FIX_ASPECT_RATIO )
cameraMatrix.at<double>(0,0) = 1.0;
- 畸变系数矩阵。 初始化为0。
distCoeffs = Mat::zeros(8, 1, CV_64F);
- 对于所有视角来说,这个函数会计算旋转和转移向量,这些向量会把物点(在模型坐标空间中给出)转换成像点(在世界坐标空间中给出)。第七个和第八个参数是一个矩阵的容器,第i个矩阵包含了第i个物点到像点的旋转和转移矩阵。
- *
double rms = calibrateCamera(objectPoints, imagePoints, imageSize, cameraMatrix,
distCoeffs, rvecs, tvecs, s.flag|CV_CALIB_FIX_K4|CV_CALIB_FIX_K5);
- 这个函数返回平均重投影偏差。
double computeReprojectionErrors( const vector<vector<Point3f> >& objectPoints,
const vector<vector<Point2f> >& imagePoints,
const vector<Mat>& rvecs, const vector<Mat>& tvecs,
const Mat& cameraMatrix , const Mat& distCoeffs,
vector<float>& perViewErrors)
{
vector<Point2f> imagePoints2;
int i, totalPoints = 0;
double totalErr = 0, err;
perViewErrors.resize(objectPoints.size());
for( i = 0; i < (int)objectPoints.size(); ++i )
{
projectPoints( Mat(objectPoints[i]), rvecs[i], tvecs[i], cameraMatrix, // project
distCoeffs, imagePoints2);
err = norm(Mat(imagePoints[i]), Mat(imagePoints2), CV_L2); // difference
int n = (int)objectPoints[i].size();
perViewErrors[i] = (float) std::sqrt(err*err/n); // save for this view
totalErr += err*err; // sum it up
totalPoints += n;
}
return std::sqrt(totalErr/totalPoints); // calculate the arithmetical mean
}
结果
这儿有一幅 9X6 的棋盘图案作为输入。我使用的是一个AXIS IP相机来拍得一组棋盘的照片并且将其保存进VID5文件夹中。我已经将其放入images/CameraCalibration文件夹中,接下来我要创建VID5.XML文件来描述哪些图像将会被使用。
<?xml version="1.0"?>
<opencv_storage>
<images>
images/CameraCalibration/VID5/xx1.jpg
images/CameraCalibration/VID5/xx2.jpg
images/CameraCalibration/VID5/xx3.jpg
images/CameraCalibration/VID5/xx4.jpg
images/CameraCalibration/VID5/xx5.jpg
images/CameraCalibration/VID5/xx6.jpg
images/CameraCalibration/VID5/xx7.jpg
images/CameraCalibration/VID5/xx8.jpg
</images>
</opencv_storage>
然后将image/CameraCalibration/VID5/VID5.XML作为设置文件的输入。这儿有一幅棋盘图案在程序运行时被找到的图像:
当程序消除畸变后我们得到:
对于不对称圆形图案我们也可以这么做,只要将输入的宽度设为4,高度设为11即可。这回我们使用ID为1的即时相机来作为输入。这次我们应该看到下面这个图:
在两种情况下在各自的输出XML/YAML文件中你都能看到相机矩阵和畸变矩阵:
<Camera_Matrix type_id="opencv-matrix">
<rows>3</rows>
<cols>3</cols>
<dt>d</dt>
<data>
6.5746697944293521e+002 0. 3.1950000000000000e+002 0.
6.5746697944293521e+002 2.3950000000000000e+002 0. 0. 1.</data></Camera_Matrix>
<Distortion_Coefficients type_id="opencv-matrix">
<rows>5</rows>
<cols>1</cols>
<dt>d</dt>
<data>
-4.1802327176423804e-001 5.0715244063187526e-001 0. 0.
-5.7843597214487474e-001</data></Distortion_Coefficients>
然后这些值对于你的程序来说是定值,调用initUndistortRectifyMap和remap函数来消除畸变,从廉价和低质量的相机中获取没有畸变的图像。
你也许想从YOUTUBE上看一个运行的例子。