使用OpenCV相机校准
相机已经存在了很长时间。然而,随着20世纪后期引入便宜的针孔相机,它们在日常生活中成为常见的事件。不幸的是,这种廉价的价格是:显著的扭曲。幸运的是,这些是常数,校准和一些重新映射,我们可以纠正这一点。此外,通过校准,您还可以确定相机的自然单位(像素)与实际单位之间的关系(例如毫米)。
理论
对于失真,OpenCV考虑到径向和切向因素。对于径向因子,使用以下公式:
因此,对于坐标处的未失真像素点 (x,y),其在失真图像上的位置将为。径向变形的存在表现为“barrel”或“fish-eye”效应的形式
由于摄像镜头不完全平行于成像平面,因此会发生切向畸变。它可以通过公式表示:
所以我们有五个失真参数,它们在OpenCV中呈现为具有5列的一行矩阵:
现在对于单位转换,我们使用以下公式:
这里通过使用单应性坐标系(w = Z)来解释w的存在。未知参数是fx和fy(摄像机焦距)和(cx,cy),它们是以像素坐标表示的光学中心。如果对于两个轴,使用给定的a纵横比(通常为1)的公共焦距,则fy=fx∗a a,并且在上面的公式中,我们将具有单个焦距f。包含这四个参数的矩阵称为相机矩阵。虽然失真系数是相同的,无论使用的相机分辨率,这些应该与校准分辨率的当前分辨率一起缩放。
确定这两个矩阵的过程是校准。这些参数的计算是通过基本几何方程来完成的。所使用的方程取决于所选择的校准对象。目前OpenCV支持三种校准对象类型:
- 古典黑白棋盘
- 对称圆形图案
- 不对称圆形图案
基本上,您需要使用相机拍摄这些图案的快照,并让OpenCV找到它们。每个发现的模式产生一个新的方程。要解决方程式,您需要至少预定数量的模式快照来形成一个精心设计的方程式。这个数字对于棋盘图案更高,而对于圆形图案则更小。例如,理论上棋盘图案至少需要两个快照。然而,实际上我们的输入图像中存在大量的噪音,所以为了获得良好的效果,您可能需要至少10个不同位置的输入图形快照。
目标
示例应用程序将:
- 确定失真矩阵
- 确定相机矩阵
- 从相机,视频和图像文件列表中输入
- 从XML / YAML文件读取配置
- 将结果保存到XML / YAML文件中
- 计算重新投影误差
源代码
您也可以在samples/cpp/tutorial_code/calib3d/camera_calibration/OpenCV源库的文件夹中找到源代码,或者从这里下载。该程序有一个参数:其配置文件的名称。如果没有给出,那么它将尝试打开一个名为“default.xml”的文件。以下是 XML格式的示例配置文件。在配置文件中,您可以选择将相机用作输入,视频文件或图像列表。如果您选择最后一个,您将需要创建一个配置文件,您可以枚举要使用的图像。这是一个例子。要记住的重要部分是图像需要使用绝对路径或应用程序工作目录中的相对路径进行指定。
该应用程序从配置文件读取设置启动。虽然这是它的重要组成部分,但与本教程的主题无关:摄像机校准。因此,我选择不在这里发布该部分的代码。技术背景如何做到这一点,你可以在文件输入和输出中找到使用XML和YAML文件的教程。
说明
- 阅读设置
Settings s;
const string inputSettingsFile = argc > 1 ? argv[1] : "default.xml";
FileStorage fs(inputSettingsFile, FileStorage::READ); // Read the settings
if (!fs.isOpened())
{
cout << "Could not open the configuration file: \"" << inputSettingsFile << "\"" << endl;
return -1;
}
fs["Settings"] >> s;
fs.release(); // close Settings file
为此我使用简单的OpenCV类输入操作。阅读文件后,我有一个额外的后处理功能来检查输入的有效性。只有所有的输入都是好的,那么goodInput变量才是真的。
- 获取下一个输入,如果它失败或我们有足够的 - 校准
之后,我们有一个很大的循环,我们进行以下操作:从图像列表,相机或视频文件中获取下一个图像。如果这样做失败或者我们有足够的图像,那么我们运行校准过程。在图像的情况下,我们退出循环,否则剩余的帧将通过从DETECTION模式切换到CALIBRATED模式而不会失真(如果选项被设置)。
for(;;)
{
Mat view;
bool blinkOutput = false;
view = s.nextImage();
//----- If no more image, or got enough, then stop calibration and show result -------------
if( mode == CAPTURING && imagePoints.size() >= (size_t)s.nrFrames )
{
if( runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints))
mode = CALIBRATED;
else
mode = DETECTION;
}
if(view.empty()) // If there are no more images stop the loop
{
// if calibration threshold was not reached yet, calibrate now
if( mode != CALIBRATED && !imagePoints.empty() )
runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints);
break;
}
对于某些相机,我们可能需要翻转输入图像。这里我们也这样做。
- 查找当前输入中的模式
上面提到的方程式的形成旨在找到输入中的主要模式:在棋盘的情况下,这是方格和圆圈的角落,以及圆圈本身。这些的位置将形成将被写入pointBuf向量的结果。
vector<Point2f> pointBuf;
bool found;
int chessBoardFlags = CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE;
if(!s.useFisheye) {
// fast check erroneously fails with high distortions like fisheye
chessBoardFlags |= CALIB_CB_FAST_CHECK;
}
switch( s.calibrationPattern ) // Find feature points on the input format
{
case Settings::CHESSBOARD:
found = findChessboardCorners( view, s.boardSize, pointBuf, chessBoardFlags);
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;
default:
found = false;
break;
}
根据输入模式的类型,您可以使用cv :: findChessboardCorners或cv :: findCirclesGrid函数。对于他们来说,您传递当前的图像和板的大小,您将获得图案的位置。此外,它们返回一个布尔变量,它指出在输入中是否找到了模式(我们只需要考虑那些是真实的图像!)。
然后在相机的情况下,只有当输入延迟时间过去时,才能拍摄相机图像。这样做是为了让用户移动棋盘并获得不同的图像。类似的图像产生类似的等式,并且在校准步骤中的类似方程将形成不适当的问题,因此校准将失败。对于方形图像,角落的位置只是近似值。我们可以通过调用cv :: cornerSubPix函数来改进这一点。它将产生更好的校准结果。之后,我们向imagePoints向量添加一个有效的输入结果,将所有方程集合到一个容器中。最后,为了可视化反馈的目的,我们将使用cv :: findChessboardCorners在输入图像上绘制找到的点 功能。
if ( found) // If done with success,
{
// improve the found corners' coordinate accuracy for chessboard
if( s.calibrationPattern == Settings::CHESSBOARD)
{
Mat viewGray;
cvtColor(view, viewGray, COLOR_BGR2GRAY);
cornerSubPix( viewGray, pointBuf, Size(11,11),
Size(-1,-1), TermCriteria( TermCriteria::EPS+TermCriteria::COUNT, 30, 0.1 ));
}
if( mode == CAPTURING && // For camera only take new samples after delay time
(!s.inputCapture.isOpened() || clock() - prevTimestamp > s.delay*1e-3*CLOCKS_PER_SEC) )
{
imagePoints.push_back(pointBuf);
prevTimestamp = clock();
blinkOutput = s.inputCapture.isOpened();
}
// Draw the corners.
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);
如果我们运行校准,并使用失真系数得到相机的矩阵,我们可能需要使用cv :: undistort函数来校正图像:
if( mode == CALIBRATED && s.showUndistorsed )
{
Mat temp = view.clone();
if (s.useFisheye)
cv::fisheye::undistortImage(temp, view, cameraMatrix, distCoeffs);
else
undistort(temp, view, cameraMatrix, distCoeffs);
}
然后我们显示图像并等待一个输入键,如果这是你,我们切换失真消除,如果是g,我们再次启动检测过程,最后为ESC键退出应用程序:
imshow("Image View", view);
char key = (char)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();
}
- 显示图像的失真消除
当您使用图像列表时,无法删除循环中的失真。因此,您必须在循环后执行此操作。利用这一点现在我将扩展cv :: undistort函数,其实际上首先调用cv :: initUndistortRectifyMap来查找转换矩阵,然后使用cv :: remap函数执行转换。因为成功的校准映射计算需要做一次,使用这种扩展形式可以加快你的应用程序:
if( s.inputType == Settings::IMAGE_LIST && s.showUndistorsed )
{
Mat view, rview, map1, map2;
if (s.useFisheye)
{
Mat newCamMat;
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize,
Matx33d::eye(), newCamMat, 1);
fisheye::initUndistortRectifyMap(cameraMatrix, distCoeffs, Matx33d::eye(), newCamMat, imageSize,
CV_16SC2, map1, map2);
}
else
{
initUndistortRectifyMap(
cameraMatrix, distCoeffs, Mat(),
getOptimalNewCameraMatrix(cameraMatrix, distCoeffs, imageSize, 1, imageSize, 0), imageSize,
CV_16SC2, map1, map2);
}
for(size_t i = 0; i < s.imageList.size(); i++ )
{
view = imread(s.imageList[i], IMREAD_COLOR);
if(view.empty())
continue;
remap(view, rview, map1, map2, INTER_LINEAR);
imshow("Image View", rview);
char c = (char)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 << endl;
if (ok)
saveCameraParams(s, imageSize, cameraMatrix, distCoeffs, rvecs, tvecs, reprojErrs, imagePoints,
totalAvgErr);
return ok;
}
我们在cv :: calibrateCamera功能的帮助下进行校准。它具有以下参数:
- 对象点。这是Point3f矢量的矢量,每个输入图像描述图案的外观。如果我们有平面图案(如棋盘),那么我们可以简单地将所有Z坐标设置为零。这是收集这些重要的要点。因为我们使用一个单一的图案,所有的输入图像,我们可以计算这一次,并将其乘以所有其他输入视图。我们用calcBoardCornerPositions函数计算角点:
static 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(j*squareSize, 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((2*j + i % 2)*squareSize, i*squareSize, 0));
break;
default:
break;
}
}
然后将其乘以:
vector <vector <Point3f>> objectPoints(1);
calcBoardCornerPositions(s.boardSize,s.squareSize,objectPoints [0],s.calibrationPattern);
objectPoints.resize(imagePoints.size(),objectPoints [0]);
- 图像点。这是一个Point2f向量的向量,对于每个输入图像,它包含重要点的坐标(棋盘的角和圆形图案的圆的中心)。我们已经从cv :: findChessboardCorners或cv :: findCirclesGrid函数收集了这个。我们只需要通过它。
- 从相机,视频文件或图像获取的图像的大小。
- 相机矩阵。如果我们使用固定长宽比选项,我们需要设置fx:
cameraMatrix = Mat :: eye(3,3,CV_64F);
if(s.flag&CALIB_FIX_ASPECT_RATIO)
cameraMatrix.at < double >(0,0)= s.aspectRatio;
- 失真系数矩阵。初始化为零。
distCoeffs = Mat::zeros(8, 1, CV_64F);
- 对于所有视图,函数将计算将对象点(在模型坐标空间中给出)转换为图像点(在世界坐标空间中给出)的旋转和平移向量。第7和第8参数是在第i个位置包含第i个对象点到第i个图像点的旋转和平移向量的矩阵的输出向量。
- 最后的争论就是旗帜。您需要在此处指定选项,例如修复焦距的宽高比,假定零切向失真或固定主点。
double rms = calibrateCamera(objectPoints,imagePoints,imageSize,cameraMatrix,
distCoeffs,rvecs,tvecs,s.flag | CV_CALIB_FIX_K4 | CV_CALIB_FIX_K5);
- 该函数返回平均重投影误差。这个数字给出了发现参数精度的良好估计。这应尽可能接近零。给定内在,失真,旋转和平移矩阵,我们可以通过使用cv :: projectPoints将对象点转换为图像点来计算一个视图的错误。然后我们计算出我们与转换和角/圆搜索算法之间的绝对范数。为了找到平均误差,我们计算出所有校准图像计算出的误差的算术平均值。
static 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, bool fisheye)
{
vector<Point2f> imagePoints2;
size_t totalPoints = 0;
double totalErr = 0, err;
perViewErrors.resize(objectPoints.size());
for(size_t i = 0; i < objectPoints.size(); ++i )
{
if (fisheye)
{
fisheye::projectPoints(objectPoints[i], imagePoints2, rvecs[i], tvecs[i], cameraMatrix,
distCoeffs);
}
else
{
projectPoints(objectPoints[i], rvecs[i], tvecs[i], cameraMatrix, distCoeffs, imagePoints2);
}
err = norm(imagePoints[i], imagePoints2, NORM_L2);
size_t n = objectPoints[i].size();
perViewErrors[i] = (float) std::sqrt(err*err/n);
totalErr += err*err;
totalPoints += n;
}
return std::sqrt(totalErr/totalPoints);
}
结果
让这个输入棋盘模式的大小为9 X 6.我已经使用了一个AXIS网络摄像机来创建几个板卡的快照并将其保存到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>
然后images/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.5746697944293521 e +002 0. 3.1950000000000000 e +002 0。
6.5746697944293521 e +002 2.3950000000000000 e +002 0. 0. 1。</ data > </ camera_matrix >
< distortion_coefficients type_id = “opencv-matrix” >
< rows > 5 </ rows >
< cols > 1 </ cols >
< dt > d </ dt >
< data >
-4.1802327176423804 e-001 5.0715244063187526 e-001 0. 0。
-5.7843597214487474 e-001 </ data > </ distortion_coefficients >
将这些值作为常量添加到程序中,调用cv :: initUndistortRectifyMap和cv :: remap函数来消除失真,并享受无瑕疵的便宜和低质量相机输入。