上一个教程: 使用方格棋盘进行相机校准
下一个教程: 纹理对象的实时位姿估计
| |
原始作者 | 伯纳特·加博尔 |
兼容性 | OpenCV >= 4.0 |
相机已经问世很长时间了。然而,随着廉价针孔相机在 20 世纪末的引入,它们成为我们日常生活中常见的物品。不幸的是,这种廉价性是有代价的:严重的畸变。幸运的是,这些都是常数,通过校准和一些重新映射我们可以校正它们。此外,通过校准,您还可以确定相机的自然单位(像素)与实际单位(例如毫米)之间的关系。
Theory
对于畸变,OpenCV 考虑了径向和切向因子。对于径向因子,使用以下公式
\[x_{distorted} = x( 1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \\ y_{distorted} = y( 1 + k_1 r^2 + k_2 r^4 + k_3 r^6)\]
因此,对于在 \((x,y)\) 坐标处的一个无畸变像素点,它在畸变图像上的位置将是 \((x_{distorted} y_{distorted})\)。径向畸变的存在以“桶形”或“鱼眼”效果的形式表现。
切向畸变的发生是因为成像镜头与成像平面并不完全平行。它可以通过以下公式表示
\[x_{distorted} = x + [ 2p_1xy + p_2(r^2+2x^2)] \\ y_{distorted} = y + [ p_1(r^2+ 2y^2)+ 2p_2xy]\]
因此,我们有五个畸变参数,在 OpenCV 中它们表示为一行为 5 列的矩阵
\[distortion\_coefficients=(k_1 \hspace{10pt} k_2 \hspace{10pt} p_1 \hspace{10pt} p_2 \hspace{10pt} k_3)\]
现在对于单位转换,我们使用以下公式
\[\left [ \begin{matrix} x \\ y \\ w \end{matrix} \right ] = \left [ \begin{matrix} f_x & 0 & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{matrix} \right ] \left [ \begin{matrix} X \\ Y \\ Z \end{matrix} \right ]\]
这里用投影坐标系统(且 \(w=Z\))解释了 \(w\) 的存在。未知参数为 \(f_x\) 和 \(f_y\)(相机焦距)与 \((c_x, c_y)\),它们是像素坐标中表示的光学中心。如果两个轴均使用具有给定 \(a\) 纵横比(通常为 1)的公共焦距,则 \(f_y=f_x*a\),而在上式中我们有单个焦距 \(f\)。包含这四个参数的矩阵称为相机矩阵。虽然失真系数与所用相机分辨率无关,但这些系数应该与当前分辨率一起从标定分辨率中缩放。
确定这两个矩阵的过程就是标定。通过基本几何方程计算这些参数。所使用的方程式取决于所选标定对象。目前 OpenCV 支持三类标定对象
- 经典黑白色棋盘格
- ChArUco 图案
- 对称圆形图案
- 非对称圆形图案
从根本上讲,你需要用相机拍摄这些图案,然后让 OpenCV 找到它们。每一种找到的图案都会生成一个新方程。为了求解这个方程,你需要至少预先确定的数量的图案快照,以形成一个适定方程组。棋盘格图案需要更高的数字,而圆形图案需要更低一些。例如,从理论上讲,棋盘格图案至少需要两张快照。但是,在实践中我们的输入图像中存在大量的噪声,因此为了获得良好的效果,你可能至少需要对输入图案在不同的位置拍摄 10 张优质快照。
目标
示例应用程序将
- 确定失真矩阵
- 确定相机矩阵
- 从此处获取输入:相机、视频和图像文件列表
- 从 XML/YAML 文件读取配置
- 将结果保存在 XML/YAML 文件中
- 计算重新投影误差
源代码
您还可以在此处下载 OpenCV 源库的 samples/cpp/tutorial_code/calib3d/camera_calibration/
文件夹中的源代码,或 从这里下载。要了解该程序的用法,请使用 -h
参数运行它。该程序有一个重要参数:其配置文件的名称。如果未提供,它将尝试打开名为“default.xml”的文件。 此处有一个示例配置文件(XML 格式)。在配置文件中,您可以选择将照相机用作输入、一个视频文件或一个图像列表。如果您选择最后一种,您需要创建一个配置文件,其中列出要使用的图像。以下是 此示例。请记住的重要部分是需要使用绝对路径或您应用工作目录的相对路径来指定图像。您可以在上面提到的样本目录中找到所有这些内容。
该应用程序通过读取配置文件中的设置来启动。尽管这是其重要组成部分,但它与本教程的主题无关:照相机校准。因此,我选择在此处不发布该部分的代码。有关如何执行此操作的技术背景,您可以在 使用 XML 和 YAML 文件进行文件输入和输出 教程中找到。
说明
读取设置
设置 s;
const string inputSettingsFile = parser.get<string>(0);
FileStorage fs(inputSettingsFile, FileStorage::READ);
if (!fs.isOpened())
{
cout << "无法打开配置文件:\"" << inputSettingsFile << "\"" << endl;
parser.printMessage();
return -1;
}
fs["Settings"] >> s;
fs.release();
为此,我使用了简单的 OpenCV 类输入操作。在读取文件后,我增加了一个后处理函数,它检查输入的有效性。只有所有输入都正确,变量 goodInput 才为真。
获取下一个输入,如果它失败或我们已经有足够多的输入,就会进行校准
在此之后我们有一个大循环,其中我们执行以下操作:从图像列表、摄像头或视频文件中获取下一张图像。如果这失败了或者我们有足够多的图像,那么我们运行校准过程。如果是图像,我们跳出循环,否则剩余的帧将通过从检测模式转换为校正模式(如果设置了选项)来校正失真。
对于(;;)
{
Mat 视图;
bool 闪烁输出 = false;
视图 = s.下一张图像();
if(模式 == 捕获 && imagePoints.大小() >= (size_t)s.nrFrames )
{
if(runCalibrationAndSave(s, 图像大小, 相机矩阵, distCoeffs, imagePoints, grid_width,
release_object))
模式 = 校准;
else
模式 = 检测;
}
if(view.empty())
{
if(模式 != 校准 && !imagePoints.empty() )
runCalibrationAndSave(s, 图像大小, 相机矩阵, distCoeffs, imagePoints, grid_width,
release_object);
中断;
}
对于某些相机,我们可能需要翻转输入图像。我们在这里也执行此操作。
寻找当前输入中的模式
我上面提到的方程的形成旨在找到输入中的主要模式:对于棋盘来说,这是方格的角,对于圆来说,就是圆本身。ChArUco 板等效于棋盘,但角由 ArUco 标记匹配。这些位置将形成结果,该结果将写入pointBuf 向量中。
vector<Point2f> pointBuf;
bool 找到;
int 棋盘标志 = CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE;
if(!s.useFisheye) {
棋盘标志 |= CALIB_CB_FAST_CHECK;
}
switch( s.calibrationPattern )
{
case 设置::棋盘
找到 = findChessboardCorners(视图, s.boardSize, pointBuf, chessBoardFlags);
中断;
case 设置::CHARUCOBOARD
ch_detector.detectBoard( 视图, pointBuf, markerIds);
找到 = pointBuf.size() == (size_t)((s.boardSize.height - 1)*(s.boardSize.width - 1));
中断;
case 设置::CIRCLES_GRID
找到 = findCirclesGrid( 视图, s.boardSize, pointBuf );
中断;
case 设置::ASYMMETRIC_CIRCLES_GRID
找到 = findCirclesGrid( 视图, s.boardSize, pointBuf, CALIB_CB_ASYMMETRIC_GRID );
中断;
默认情况下:
找到 = false;
中断;
}
根据所用输入模式的类型,您可以使用 cv::findChessboardCorners 或 cv::findCirclesGrid 函数,或 cv::aruco::CharucoDetector::detectBoard 方法。对于所有这些,您都会传递当前图像和棋盘的尺寸,您将获得这些棋盘格的位置。 cv::findChessboardCorners 和 cv::findCirclesGrid 返回一个布尔变量,该变量说明输入中是否找到了该图案(我们只需要考虑值为 true 的那些图像!)。CharucoDetector::detectBoard
可能检测出部分可见图案,并返回可见内部角点的坐标和 ID。
- 注
- 棋盘格、圆形网格和 ChArUco 的棋盘格尺寸和匹配点的数量不同。与棋盘相关的算法都将内部角点数量预计为棋盘格的宽度和高度。圆形网格的棋盘格尺寸只是这两个网格维度上的圆形数量。ChArUco 棋盘格尺寸以方块为单位进行定义,但检测结果是内部角点的列表,因此这两个维度都比方块数量少 1。
在相机的情况下,我们只在传入延迟时间后拍摄相机图像。这样做是为了允许用户移动棋盘格并获取不同的图像。相似的图像将产生相似的方程式,校准步骤中的相似的方程式将形成一个不正定的问题,导致校准失败。对于正方形图像,角点位置只是近似的。我们可以通过调用 cv::cornerSubPix 函数来改进这一结果。(winSize
用于控制搜索窗口的边长。它的默认值为 11。可以使用命令行参数 --winSize=<number>
更改 winSize
。)它将产生更好的校准结果。在此之后,我们将一个有效的输入结果添加到 imagePoints 向量中,以将所有方程式收集到一个容器中。最后,出于可视化反馈目的,我们将使用 cv::findChessboardCorners 函数在输入图像上绘制找到的点。
if (found)
{
if( s.calibrationPattern == Settings::CHESSBOARD)
{
Mat viewGray;
cvtColor(view, viewGray, COLOR_BGR2GRAY);
cornerSubPix( viewGray, pointBuf, Size(winSize,winSize),
Size(-1,-1), TermCriteria( TermCriteria::EPS+TermCriteria::COUNT, 30, 0.0001 ));
}
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();
}
if(s.calibrationPattern == Settings::CHARUCOBOARD)
drawChessboardCorners( view,
cv::Size(s.boardSize.width-1, s.boardSize.height-1), Mat(pointBuf), found );
else
drawChessboardCorners( view, s.boardSize, Mat(pointBuf), found );
}
指定图像或矩形的尺寸的模板类。
定义 types.hpp:335
向用户展示状态和结果,以及应用程序的命令行控制
此部分显示图像上的文本输出。
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.showUndistorted)
msg =
cv::format(
"%d/%d Undist", (
int)imagePoints.size(), s.nrFrames );
else
msg =
cv::format(
"%d/%d", (
int)imagePoints.size(), s.nrFrames );
}
putText( view, msg, textOrigin, 1, 1, mode == CALIBRATED ? GREEN : RED);
if( blinkOutput )
bitwise_not(view, view);
string format(const char *fmt,...)
使用 printf 式表达式格式化返回文本字符串。
如果我们运行了校准并获取了摄像机矩阵连同畸变系数,我们可能需要使用 cv::undistort 函数更正图像
if( mode == CALIBRATED && s.showUndistorted )
{
Mat temp = view.clone();
if (s.useFisheye)
{
Mat newCamMat;
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize,
Matx33d::eye(), newCamMat, 1);
}
else
undistort(temp, view, cameraMatrix, distCoeffs);
}
void undistortImage(InputArray distorted, OutputArray undistorted, InputArray K, InputArray D, InputArray Knew=cv::noArray(), const Size &new_size=Size())
转换图像以补偿鱼眼镜头畸变。
然后我们显示图像,等待一个输入键。如果输入是u,我们切换畸变校正。如果输入是g,我们重新开始检测流程。最后,如果按下ESC键,我们退出应用程序。
imshow("图像视图", view);
char key = (char)waitKey(s.inputCapture.isOpened() ? 50 : s.delay);
if( key == ESC_KEY )
中断;
if( key == 'u' && mode == CALIBRATED )
s.showUndistorted = !s.showUndistorted;
if( s.inputCapture.isOpened() && key == 'g' )
{
mode = CAPTURING;
imagePoints.clear();
}
为图像显示畸变校正
使用图像列表时,无法在循环内部进行畸变校正。因此,必须在循环之后执行此操作。利用该优势,我将扩展 cv::undistort 函数,该函数实际上首先调用 cv::initUndistortRectifyMap 查找变换矩阵,然后使用 cv::remap 函数执行变换。因为,在成功完成校正之后,仅需执行一次 map 计算,使用此扩展形式可以加速应用程序。
if( s.inputType == Settings::IMAGE_LIST && s.showUndistorted && !cameraMatrix.empty())
{
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,
}
else
{
initUndistortRectifyMap(
cameraMatrix, distCoeffs, Mat(),
getOptimalNewCameraMatrix(cameraMatrix, distCoeffs, imageSize, 1, imageSize, 0), imageSize,
}
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("图像视图", rview);
char c = (char)waitKey();
if( c == ESC_KEY || c == 'q' || c == 'Q' )
中断;
}
}
#define CV_16SC2
定义 interface.h:107
校正和保存
由于每台相机仅需执行一次校正,因此在成功完成校正后将结果保存起来非常有意义。以后你可以将这些值加载到你的程序中。因此我们首先进行校正,如果校正成功,我们将结果保存到 OpenCV 样式的 XML 或 YAML 文件中,具体取决于你给出的配置文件扩展名。
因此在第一个函数中,我们只需将这两个过程分开。因为我们想要保存许多校准变量,因此在此创建这些变量并将它们两个都传递给校准和保存函数。同样,我不会展示保存部分,因为那与校准部分几乎没有共同之处。浏览源文件以找出如何以及是什么
bool runCalibrationAndSave(Settings& s, Size imageSize, Mat& cameraMatrix, Mat& distCoeffs,
vector<vector<Point2f> > imagePoints, float grid_width, bool release_object)
{
vector<Mat> rvecs, tvecs;
vector<float> reprojErrs;
double totalAvgErr = 0;
vector<Point3f> newObjPoints;
bool ok = runCalibration(s, imageSize, cameraMatrix, distCoeffs, imagePoints, rvecs, tvecs, reprojErrs,
totalAvgErr, newObjPoints, grid_width, release_object);
cout << (ok ? "校准成功" : "校准失败")
<< ". avg re projection error = " << totalAvgErr << endl;
if (ok)
saveCameraParams(s, imageSize, cameraMatrix, distCoeffs, rvecs, tvecs, reprojErrs, imagePoints,
totalAvgErr, newObjPoints);
return ok;
}
我们在 cv::calibrateCameraRO 函数的帮助下进行校准。它具有下列参数
- 对象点。这是一个 Point3f 向量的向量,针对每个输入图像描述图案应该如何显现。如果我们有一个平面图案(例如棋盘),那么我们只需将所有 Z 坐标设为零。这是一个集合,指示这些重要点出场的点。因为我们对所有输入图像使用单个图案,所以我们可以只计算一次,并将其乘以所有其他输入视图。我们用 calcBoardCornerPositions 函数计算角点
static void calcBoardCornerPositions(Size boardSize, float squareSize, vector<Point3f>& corners,
Settings::Pattern patternType )
{
corners.clear();
switch(patternType)
{
case 设置::棋盘
case 设置::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));
}
}
中断;
case 设置::CHARUCOBOARD
for (int i = 0; i < boardSize.height - 1; ++i) {
for (int j = 0; j < boardSize.width - 1; ++j) {
corners.push_back(Point3f(j*squareSize, i*squareSize, 0));
}
}
中断;
case 设置::ASYMMETRIC_CIRCLES_GRID
for (int i = 0; i < boardSize.height; i++) {
对于 (int j = 0; j < boardSize.width; j++) {
corners.push_back(
Point3f((2 * j + i % 2)*squareSize, i*squareSize, 0));
}
}
中断;
默认情况下:
中断;
}
}
Point3_< float > Point3f
定义 types.hpp:290
然后乘以vector<vector<Point3f> > objectPoints(1);
calcBoardCornerPositions(s.boardSize, s.squareSize, objectPoints[0], s.calibrationPattern);
objectPoints[0][s.boardSize.width - 1].x = objectPoints[0][0].x + grid_width;
newObjPoints = objectPoints[0];
objectPoints.resize(imagePoints.size(),objectPoints[0]);
- 注
- 如果您的校准板不准确、未测量、大约平面目标(使用现成的打印机在纸上打印棋盘格图案是最方便的校准目标,它们中的大多数不够准确),可以使用 [254] 中的方法极大地提高估计的相机内参的准确性。将在提供命令行参数
-d=<number>
时调用此新的校准方法。在以上代码片段中,grid_width
实际上是 -d=<number>
设置的值。它是图案网格点左上角 (0, 0, 0) 和右上角 (s.squareSize*(s.boardSize.width-1), 0, 0) 之间测得的距离。它应该使用尺子或游标卡尺精确测量。校准后,newObjPoints 将使用对象的 3D 坐标更新。
- 图像点。这是一个 Point2f 向量向量,对于每一个输入图像,它包含重要点的坐标(棋盘格的角和圆形图案的圆心)。我们已经通过 cv::findChessboardCorners 或 cv::findCirclesGrid 函数收集了这些信息。我们只需要传递这些信息。
- 从相机、视频文件或图像获取的图像的大小。
- 要固定的物体点的索引。将它设置为 -1 以请求标准校准方法。如果要使用新的物体释放方法,请将其设置为校准板网格右上角点的索引。有关详细说明,请参阅 cv::calibrateCameraRO。
int iFixedPoint = -1;
如果 (release_object)
iFixedPoint = s.boardSize.width - 1;
- 相机矩阵。如果我们使用了固定纵横比选项,我们需要设置 \(f_x\)
cameraMatrix = Mat::eye(3, 3,
CV_64F);
如果( !s.useFisheye && s.flag & CALIB_FIX_ASPECT_RATIO )
cameraMatrix.at<double>(0,0) = s.aspectRatio;
#define CV_64F
定义 interface.h:79
- 畸变系数矩阵。用零初始化。
distCoeffs = Mat::zeros(8, 1,
CV_64F);
- 对于所有视图,函数将计算旋转和平移向量,将对象点(给定模型坐标空间)转换为图像点(给定世界坐标空间)。第 7 和第 8 个参数是矩阵的输出向量,其中在第 i 个位置包含第 i 个对象点到第 i 个图像点的旋转和平移向量。
- 校准图案点的更新后的输出向量。使用标准校准方法时忽略此参数。
- 最后一个参数是标记。您需要在此处指定选项,例如固定焦距的长宽比,假设切向失真为零或固定主点。在此,我们使用 CALIB_USE_LU 以获得更快的校准速度。
rms = calibrateCameraRO(objectPoints, imagePoints, imageSize, iFixedPoint,
cameraMatrix, distCoeffs, rvecs, tvecs, newObjPoints,
s.flag | CALIB_USE_LU);
- 此函数返回平均重新投影误差。此数字可对已找到参数的精度提供良好的估计。此值应尽可能接近零。给定内部、失真、旋转和平移矩阵,我们可以使用 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 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>
然后在配置文件中传递 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.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>
将这些值作为常量添加到您的程序中,调用 cv::initUndistortRectifyMap 和 cv::remap 函数以消除失真并享受廉价和低质量相机的无失真输入。
您可以在 YouTube 上看到此运行时示例。