上一教程: 使用方形棋盘进行相机标定
下一教程: 纹理物体的实时姿态估计
| |
| 原始作者 | Bernát Gábor |
| 兼容性 | OpenCV >= 4.0 |
相机已经存在了很长很长时间。然而,随着20世纪末廉价的小孔成像相机的出现,它们在我们的日常生活中变得随处可见。不幸的是,这种廉价性也带来了代价:显著的畸变。幸运的是,这些畸变是恒定的,通过标定和一些重映射,我们可以对其进行校正。此外,通过标定,您还可以确定相机固有单位(像素)与真实世界单位(例如毫米)之间的关系。
理论
对于畸变,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\) 的存在是通过使用单应性坐标系来解释的(且 \(w=Z\))。未知参数是 \(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 / JSON文件进行文件输入和输出教程中找到。
解释
读取设置
Settings s;
const string inputSettingsFile = parser.get<string>(0);
if (!fs.isOpened())
{
cout << "Could not open the configuration file: \"" << inputSettingsFile << "\"" << endl;
parser.printMessage();
return -1;
}
fs["Settings"] >> s;
fs.release();
为此,我使用了简单的OpenCV类输入操作。读取文件后,我还有一个额外的后处理函数来检查输入的有效性。只有当所有输入都良好时,goodInput变量才会为真。
获取下一个输入,如果失败或我们有足够的输入 - 进行标定
在此之后,我们有一个大循环,执行以下操作:从图像列表、相机或视频文件中获取下一张图像。如果失败或我们有足够的图像,则运行标定过程。在图像的情况下,我们跳出循环,否则,如果设置了选项,剩余的帧将通过从检测模式更改为已标定模式来消除畸变。
for(;;)
{
bool blinkOutput = false;
view = s.nextImage();
if( mode == CAPTURING && imagePoints.size() >= (size_t)s.nrFrames )
{
if(runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints, grid_width,
release_object))
mode = CALIBRATED;
else
mode = DETECTION;
}
{
if( mode != CALIBRATED && !imagePoints.empty() )
runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints, grid_width,
release_object);
break;
}
对于某些相机,我们可能需要翻转输入图像。这里我们也这样做了。
在当前输入中查找图案
我上面提到的方程的形成旨在在输入中找到主要图案:对于棋盘格,这些是正方形的角点;对于圆形,则是圆形本身。ChArUco板与棋盘格类似,但角点是通过ArUco标记匹配的。这些点的位置将形成结果,并写入pointBuf向量。
vector<Point2f> pointBuf;
bool found;
if(!s.useFisheye) {
}
switch( s.calibrationPattern )
{
case Settings::CHESSBOARD
break;
case Settings::CHARUCOBOARD
ch_detector.detectBoard( view, pointBuf, markerIds);
found = pointBuf.size() == (size_t)((s.boardSize.height - 1)*(s.boardSize.width - 1));
break;
case Settings::CIRCLES_GRID
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::aruco::CharucoDetector::detectBoard方法。对于所有这些函数,您都传入当前图像和棋盘格大小,然后将获得图案的位置。cv::findChessboardCorners和cv::findCirclesGrid返回一个布尔变量,表示是否在输入中找到了图案(我们只需考虑那些为真的图像!)。CharucoDetector::detectBoard可以检测部分可见的图案,并返回可见内角的坐标和ID。
- 注意
- 棋盘格、圆形网格和ChArUco板的板尺寸和匹配点数量是不同的。所有与棋盘格相关的算法都期望内角数量作为棋盘格的宽度和高度。圆形网格的板尺寸只是两个网格维度上的圆圈数量。ChArUco板的尺寸以正方形定义,但检测结果是内角列表,这就是为什么在两个维度上都小1。
然后,对于相机,我们只有在输入延迟时间过去后才拍摄相机图像。这样做是为了允许用户移动棋盘格并获取不同的图像。相似的图像会导致相似的方程,而标定步骤中的相似方程将形成一个不适定问题,因此标定将失败。对于方形图像,角点的位置只是近似的。我们可以通过调用cv::cornerSubPix函数来改进这一点。(winSize用于控制搜索窗口的边长。其默认值为11。winSize可以通过命令行参数--winSize=<number>更改。)它将产生更好的标定结果。在此之后,我们将有效输入结果添加到imagePoints向量中,以将所有方程收集到一个容器中。最后,为了可视化反馈目的,我们将使用cv::findChessboardCorners函数在输入图像上绘制找到的点。
if (found)
{
if( s.calibrationPattern == Settings::CHESSBOARD)
{
cvtColor(view, viewGray, COLOR_BGR2GRAY);
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)
else
}
向用户显示状态和结果,以及应用程序的命令行控制
这部分在图像上显示文本输出。
string msg = (mode == CAPTURING) ? "100/100"
mode == CALIBRATED ? "Calibrated" : "Press 'g' to start";
int baseLine = 0;
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 )
如果我们运行了标定并获得了包含畸变系数的相机矩阵,我们可能希望使用cv::undistort函数来校正图像。
if( mode == CALIBRATED && s.showUndistorted )
{
if (s.useFisheye)
{
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize,
Matx33d::eye(), newCamMat, 1);
}
else
undistort(temp, view, cameraMatrix, distCoeffs);
}
然后我们显示图像并等待一个输入键,如果是u,我们切换畸变消除;如果是g,我们再次开始检测过程;最后,如果是ESC键,我们退出应用程序。
char key = (char)
waitKey(s.inputCapture.isOpened() ? 50 : s.delay);
if( key == ESC_KEY )
break;
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函数执行变换。因为在成功标定后,映射计算只需进行一次,通过使用这种扩展形式,您可以加快应用程序的速度。
if( s.inputType == Settings::IMAGE_LIST && s.showUndistorted && !cameraMatrix.
empty())
{
Mat view, rview, map1, map2;
if (s.useFisheye)
{
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize,
Matx33d::eye(), newCamMat, 1);
fisheye::initUndistortRectifyMap(cameraMatrix, distCoeffs, Matx33d::eye(), newCamMat, imageSize,
}
else
{
cameraMatrix, distCoeffs,
Mat(),
}
for(size_t i = 0; i < s.imageList.size(); i++ )
{
view =
imread(s.imageList[i], IMREAD_COLOR);
continue;
remap(view, rview, map1, map2, INTER_LINEAR);
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, 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 ? "Calibration succeeded" : "Calibration failed")
<< ". avg re projection error = " << totalAvgErr << endl;
if (ok)
saveCameraParams(s, imageSize, cameraMatrix, distCoeffs, rvecs, tvecs, reprojErrs, imagePoints,
totalAvgErr, newObjPoints);
return ok;
}
@ READ
值,打开文件进行读取
定义 persistence.hpp:266
@ CALIB_USE_INTRINSIC_GUESS
定义 calib3d.hpp:3986
@ CALIB_FIX_K2
定义 calib3d.hpp:3991
@ CALIB_FIX_K4
定义 calib3d.hpp:3993
@ CALIB_FIX_K1
定义 calib3d.hpp:3990
@ CALIB_FIX_PRINCIPAL_POINT
定义 calib3d.hpp:3995
@ CALIB_FIX_K3
定义 calib3d.hpp:3992
#define INVALID
定义 multicalib.hpp:56
#define CV_32FC2
定义 interface.h:119
#define CV_16SC2
定义 interface.h:107
我们借助cv::calibrateCameraRO函数进行标定。它有以下参数:
- 目标点。这是一个Point3f向量的向量,用于描述每个输入图像中图案应如何显示。如果我们有一个平面图案(例如棋盘格),那么我们可以简单地将所有Z坐标设置为零。这是这些重要点存在的点的集合。因为我们对所有输入图像使用单个图案,所以我们可以只计算一次并将其用于所有其他输入视图。我们使用calcBoardCornerPositions函数计算角点,如下所示:
static void calcBoardCornerPositions(
Size boardSize,
float squareSize, vector<Point3f>& corners,
Settings::Pattern patternType )
{
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::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));
}
}
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[0][s.boardSize.width - 1].x = objectPoints[0][0].x + grid_width;
newObjPoints = objectPoints[0];
objectPoints.resize(imagePoints.size(),objectPoints[0]);
- 注意
- 如果您的标定板不准确、未测量或大致为平面目标(使用市售打印机在纸上打印的棋盘格图案是最方便的标定目标,但大多数不够准确),则可以利用[258]中的方法,显著提高估计相机内参的准确性。如果提供了命令行参数
-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;
if (release_object)
iFixedPoint = s.boardSize.width - 1;
- 相机矩阵。如果我们使用了固定纵横比选项,我们需要设置 \(f_x\)。
cameraMatrix = Mat::eye(3, 3,
CV_64F);
if( !s.useFisheye && s.flag & CALIB_FIX_ASPECT_RATIO )
cameraMatrix.
at<
double>(0,0) = s.aspectRatio;
- 畸变系数矩阵。初始化为零。
distCoeffs = Mat::zeros(8, 1,
CV_64F);
#define CV_64F
Definition interface.h:79
- 对于所有视图,该函数将计算旋转向量和平移向量,它们将目标点(在模型坐标空间中给出)变换到图像点(在世界坐标空间中给出)。第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这里观看此功能的运行时实例。