上一教程: 使用方形棋盘格进行相机标定
下一教程: 纹理物体的实时姿态估计
| |
原作者 | 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格式的示例配置文件。在配置文件中,您可以选择使用相机作为输入,视频文件或图像列表。如果您选择最后一个选项,则需要创建一个配置文件来枚举要使用的图像。这是一个示例。需要记住的重要一点是,需要使用绝对路径或应用程序工作目录的相对路径来指定图像。您可以在上面提到的samples目录中找到所有这些内容。
应用程序首先从配置文件中读取设置。虽然这是它重要的部分,但它与本教程的主题:相机标定无关。因此,我选择不在这里发布该部分的代码。有关如何执行此操作的技术背景,您可以在使用XML/YAML/JSON文件进行文件输入和输出教程中找到。
解释
读取设置
Settings s;
const string inputSettingsFile = parser.get<string>(0);
if (!fs.isOpened())
{
cout << "无法打开配置文件:\"" << inputSettingsFile << "\"" << endl;
parser.printMessage();
return -1;
}
fs["Settings"] >> s;
fs.release();
为此,我使用了简单的OpenCV类输入操作。读取文件后,我还有一个后处理函数来检查输入的有效性。只有当所有输入都正确时,goodInput变量才为true。
获取下一个输入,如果失败或我们有足够的输入,则进行标定
此后,我们有一个大的循环,在其中执行以下操作:从图像列表、相机或视频文件中获取下一张图像。如果失败或我们有足够的图像,则运行标定过程。对于图像,我们退出循环,否则剩余帧将通过从DETECTION模式更改为CALIBRATED模式来进行去畸变(如果设置了该选项)。
for(;;)
{
bool blinkOutput = false;
view = s.nextImage();
if( mode == CAPTURING && imagePoints.size() >= (size_t)s.nrFrames )
{
如果 (runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints, grid_width,
release_object))
mode = CALIBRATED;
否则
mode = DETECTION;
}
如果 (view.
empty()) // 如果没有更多图像,则停止循环
{
如果 (mode != CALIBRATED && !imagePoints.empty())
runCalibrationAndSave(s, imageSize, cameraMatrix, distCoeffs, imagePoints, grid_width,
release_object);
中断;
}
对于某些相机,我们可能需要翻转输入图像。我们在这里也这样做。
在当前输入中查找图案
上面提到的方程式的形成旨在查找输入中的主要模式:对于棋盘格,这些是方格的角点,对于圆形,则是圆形本身。ChArUco棋盘等效于棋盘,但角点由ArUco标记匹配。这些的位置将形成结果,并将写入pointBuf向量中。
vector<Point2f> pointBuf;
布尔型 found;
如果 (!s.useFisheye) {
}
根据 s.calibrationPattern // 查找输入格式中的特征点
{
情况 Settings::CHESSBOARD
中断;
情况 Settings::CHARUCOBOARD
ch_detector.detectBoard(view, pointBuf, markerIds);
found = pointBuf.size() == (size_t)((s.boardSize.height - 1)*(s.boardSize.width - 1));
中断;
情况 Settings::CIRCLES_GRID
中断;
情况 Settings::ASYMMETRIC_CIRCLES_GRID
found =
findCirclesGrid(view, s.boardSize, pointBuf, CALIB_CB_ASYMMETRIC_GRID);
中断;
默认:
found = false;
中断;
}
取决于输入模式的类型,您可以使用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函数在输入图像上绘制找到的点。
如果 (found) // 如果成功,
{
如果 (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));
}
如果 (mode == CAPTURING && // 仅对于相机,在延迟时间后才获取新样本
(!s.inputCapture.isOpened() || clock() - prevTimestamp > s.delay*1e-3*CLOCKS_PER_SEC))
{
imagePoints.push_back(pointBuf);
prevTimestamp = clock();
blinkOutput = s.inputCapture.isOpened();
}
如果 (s.calibrationPattern == Settings::CHARUCOBOARD)
drawChessboardCorners(view, cv::Size(s.boardSize.width-1, s.boardSize.height-1), Mat(pointBuf), found);
否则
drawChessboardCorners(view, s.boardSize, Mat(pointBuf), found);
}
向用户显示状态和结果,以及应用程序的命令行控制
此部分在图像上显示文本输出。
字符串 msg = (mode == CAPTURING) ? "100/100"
mode == CALIBRATED ? "已标定" : "按'g'键开始";
int baseLine = 0;
cv::Size textSize = cv::getTextSize(msg, 1, 1, 1, &baseLine);
cv::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);
否则
msg = cv::format("%d/%d", (int)imagePoints.size(), s.nrFrames);
}
cv::putText(view, msg, textOrigin, 1, 1, mode == CALIBRATED ? GREEN : RED);
if( blinkOutput )
cv::bitwise_not(view, view);
如果我们进行了标定并获得了带有畸变系数的相机矩阵,我们可能希望使用cv::undistort函数校正图像
if( mode == CALIBRATED && s.showUndistorted )
{
cv::Mat temp = view.clone();
if (s.useFisheye)
{
cv::Mat newCamMat;
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize,
cv::Matx33d::eye(), newCamMat, 1);
cv::fisheye::undistortImage(temp, view, cameraMatrix, distCoeffs, newCamMat);
}
否则
cv::undistort(temp, view, cameraMatrix, distCoeffs);
}
然后我们显示图像并等待输入按键,如果按键是“u”,我们将切换畸变去除,如果按键是“g”,我们将重新开始检测过程,最后,对于ESC键,我们将退出应用程序
cv::imshow("Image View", view);
char key = (char)cv::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函数执行变换。因为在成功标定后只需要进行一次地图计算,所以通过使用这种扩展形式,您可以加快应用程序的速度
if( s.inputType == Settings::IMAGE_LIST && s.showUndistorted && !cameraMatrix.empty())
{
cv::Mat view, rview, map1, map2;
if (s.useFisheye)
{
cv::Mat newCamMat;
fisheye::estimateNewCameraMatrixForUndistortRectify(cameraMatrix, distCoeffs, imageSize,
cv::Matx33d::eye(), newCamMat, 1);
fisheye::initUndistortRectifyMap(cameraMatrix, distCoeffs, cv::Matx33d::eye(), newCamMat, imageSize,
CV_16SC2, map1, map2);
}
否则
{
cameraMatrix, distCoeffs, cv::Mat(),
cv::getOptimalNewCameraMatrix(cameraMatrix, distCoeffs, imageSize, 1, imageSize, 0), imageSize,
CV_16SC2, map1, map2);
}
for(size_t i = 0; i < s.imageList.size(); i++ )
{
view = cv::imread(s.imageList[i], cv::IMREAD_COLOR);
if(view.empty())
continue;
cv::remap(view, rview, map1, map2, cv::INTER_LINEAR);
cv::imshow("Image View", rview);
char c = (char)cv::waitKey();
if( c == ESC_KEY || c == 'q' || c == 'Q' )
中断;
}
}
标定和保存
因为每个摄像机只需要进行一次标定,所以在成功标定后保存标定结果是有意义的。这样,以后就可以将这些值加载到程序中。因此,我们首先进行标定,如果标定成功,我们将结果保存到 OpenCV 风格的 XML 或 YAML 文件中,具体取决于您在配置文件中提供的扩展名。
因此,在第一个函数中,我们只是将这两个过程分开。因为我们想保存许多标定变量,所以我们将在此处创建这些变量,并将它们传递给标定和保存函数。同样,我不会展示保存部分,因为这与标定几乎没有共同之处。请浏览源文件以了解如何以及保存什么。
bool runCalibrationAndSave(Settings& s, cv::Size imageSize, cv::Mat& cameraMatrix, cv::Mat& distCoeffs,
std::vector<std::vector<cv::Point2f> > imagePoints, float grid_width, bool release_object)
{
std::vector<cv::Mat> rvecs, tvecs;
std::vector<float> reprojErrs;
double totalAvgErr = 0;
std::vector<cv::Point3f> newObjPoints;
bool ok = runCalibration(s, imageSize, cameraMatrix, distCoeffs, imagePoints, rvecs, tvecs, reprojErrs,
totalAvgErr, newObjPoints, grid_width, release_object);
std::cout << (ok ? "Calibration succeeded" : "Calibration failed")
<< ". avg re projection error = " << totalAvgErr << std::endl;
if (ok)
saveCameraParams(s, imageSize, cameraMatrix, distCoeffs, rvecs, tvecs, reprojErrs, imagePoints,
totalAvgErr, newObjPoints);
return ok;
}
@ READ
value,打开文件以进行读取
定义 persistence.hpp:266
@ CALIB_USE_INTRINSIC_GUESS
定义 calib3d.hpp:3787
@ CALIB_FIX_K2
定义 calib3d.hpp:3792
@ CALIB_FIX_K4
定义 calib3d.hpp:3794
@ CALIB_FIX_K1
定义 calib3d.hpp:3791
@ CALIB_FIX_PRINCIPAL_POINT
定义 calib3d.hpp:3796
@ CALIB_FIX_K3
定义 calib3d.hpp:3793
#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)
{
情况 Settings::CHESSBOARD
情况 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));
}
}
中断;
情况 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));
}
}
中断;
情况 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));
}
}
中断;
默认:
中断;
}
}
然后将其乘以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]);
- 注意
- 如果您的标定板不够精确,未经测量,目标大致为平面(使用现成打印机的纸张上的棋盘格图案是最方便的标定目标,并且大多数都不够精确),则可以使用来自[255] 的方法来显著提高估计的相机内参的精度。如果提供命令行参数 `-d=<number>`,则将调用此新的标定方法。在上面的代码片段中,`grid_width` 实际上是由 `-d=<number>` 设置的值。它是图案网格点左上角 (0, 0, 0) 和右上角 (s.squareSize*(s.boardSize.width-1), 0, 0) 角之间的已测距离。应该用尺子或游标卡尺精确测量。标定后,将使用改进的目标点 3D 坐标更新 newObjPoints。
- 图像点。这是一个 `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
定义 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);
}
否则
{
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 上观看运行时实例。