Skip to content

使用 OpenCV 进行相机标定

相机出现已经有很长时间了。然而,随着 20 世纪后期廉价的针孔相机的问世,它们已经成为我们日常生活中的常见之物。遗憾的是,这种低成本是有代价的:明显的畸变。幸运的是,这些畸变是常量,通过标定和一些重映射我们就能校正它。此外,通过标定你还可以确定相机的固有单位(像素)与现实世界单位(例如毫米)之间的关系。

对于畸变,OpenCV 同时考虑径向(radial)和切向(tangential)因素。对于径向因素,使用以下公式:

r2=x2+y2r^2 = x^2 + y^2

y_{distorted} = y( 1 + k_1 r^2 + k_2 r^4 + k_3 r^6)$$ 因此,对于一个位于 $(x,y)$ 坐标处的无畸变像素点,它在畸变图像上的位置将是 $(x_{distorted} y_{distorted})$。径向畸变的存在表现为"桶形"(barrel)或"鱼眼"(fish-eye)效应。 切向畸变的产生是因为成像镜头与成像平面并非完全平行。它可以用以下公式表示: $$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$。包含这四个参数的矩阵称为*相机矩阵*(camera matrix)。虽然畸变系数与所用的相机分辨率无关,但在使用时应根据当前分辨率相对于标定时的分辨率进行缩放。 确定这两个矩阵的过程就是标定。这些参数的计算通过基本的几何方程完成。所用的方程取决于所选的标定目标。目前 OpenCV 支持以下标定目标: - 经典黑白棋盘 - ChArUco 棋盘图案 - 对称圆点图案 - 非对称圆点图案 在实践中,你需要用相机拍摄这些图案的多张图像,并让 OpenCV 找到它们。每找到一个图案就会产生一个新的方程。为了求解方程,你至少需要预定数量的图案快照以构成一个良态(well-posed)方程组。棋盘图案所需的这个数量较高,而基于圆点的图案较低。例如,理论上棋盘图案至少需要两张快照。然而,由于输入图像中存在大量噪声,为了获得良好的结果,你通常需要在不同位置至少拍摄 10 张高质量的输入图案快照。 ## 目标 示例应用程序将: - 确定畸变矩阵 - 确定相机矩阵 - 从相机、视频和图像文件列表获取输入 - 从 XML/YAML 文件读取配置 - 将结果保存到 XML/YAML 文件 - 计算重投影误差 ## 源代码 你也可以在 OpenCV 源码库的 `samples/cpp/tutorial_code/calib3d/camera_calibration/` 文件夹中找到源代码,或[从此处下载](https://github.com/opencv/opencv/tree/4.x/samples/cpp/tutorial_code/calib3d/camera_calibration/camera_calibration.cpp)。要了解程序的用法,请使用 `-h` 参数运行它。该程序有一个必不可少的参数:其配置文件的名称。如果没有给出,它会尝试打开名为 "default.xml" 的配置文件。[这里有一个 XML 格式的示例配置文件](https://github.com/opencv/opencv/tree/4.x/samples/cpp/tutorial_code/calib3d/camera_calibration/in_VID5.xml)。在配置文件中,你可以选择使用相机、视频文件或图像列表作为输入。如果你选择最后一种,就需要创建一个配置文件,在其中列出要使用的图像。[这里是一个示例](https://github.com/opencv/opencv/tree/4.x/samples/cpp/tutorial_code/calib3d/camera_calibration/VID5.xml)。需要记住的重要一点是,图像必须使用绝对路径或相对于应用程序工作目录的相对路径来指定。你可以在上面提到的 samples 目录中找到所有这些内容。 应用程序启动时首先从配置文件中读取设置。虽然这是其中很重要的一部分,但它与本教程的主题——*相机标定*——无关。因此,我选择不在这里贴出那部分代码。关于如何实现这一点的技术背景,你可以在 tutorial_file_input_output_with_xml_yml 教程中找到。 ## 解析 1. **读取设置** 为此我使用了简单的 OpenCV 类输入操作。读取文件后,我有一个额外的后处理函数来检查输入的有效性。只有当所有输入都正确时,*goodInput* 变量才为 true。 1. **获取下一个输入,如果失败或已经足够多——则进行标定** 在此之后我们有一个大循环,在其中执行以下操作:从图像列表、相机或视频文件获取下一张图像。如果失败或我们已经收集到足够的图像,就运行标定过程。如果是图像输入,则跳出循环;否则剩余的帧将被去畸变(如果设置了该选项),方式是从 *DETECTION* 模式切换到 *CALIBRATED* 模式。 对于某些相机,我们可能需要翻转输入图像。这里我们也做了这一步。 1. **在当前输入中查找图案** 我上面提到的方程的建立,目的是在输入中找到主要图案:对于棋盘而言就是方格的角点,对于圆点而言就是圆点本身。ChArUco 棋盘等价于棋盘,但其角点由 ArUco 标记来匹配。这些点的位置将构成结果,并被写入 *pointBuf* 向量。 根据你使用的输入图案类型,你可以使用 cv::findChessboardCorners、cv::findCirclesGrid 函数或 cv::aruco::CharucoDetector::detectBoard 方法。对于所有这些方法,你都要传入当前图像和棋盘尺寸,然后会得到图案的位置。cv::findChessboardCorners 和 cv::findCirclesGrid 返回一个布尔变量,表示输入中是否找到了图案(我们只需考虑该值为 true 的图像!)。`CharucoDetector::detectBoard` 可以检测部分可见的图案,并返回可见内角点的坐标和 id。 **注意:** 棋盘、圆点网格和 ChArUco 的棋盘尺寸与匹配点数量是不同的。所有与棋盘相关的算法都期望以棋盘宽度和高度方向上的内角点数量作为棋盘尺寸。圆点网格的棋盘尺寸就是两个网格维度上圆点的数量。ChArUco 棋盘的尺寸以方格数定义,但检测结果是一个内角点列表,因此两个维度上都要小 1。 对于实时相机的情况,我们仅在输入延迟时间过后才捕获图像。这样做是为了让用户能够移动棋盘并获取不同的图像。相似的图像会产生相似的方程,而标定步骤中相似的方程会构成一个病态(ill-posed)问题,从而导致标定失败。对于方形图像,角点的位置只是近似的。我们可以通过调用 cv::cornerSubPix 函数来改进这一点。(`winSize` 用于控制搜索窗口的边长。其默认值为 11。可以通过命令行参数 `--winSize=<number>` 来修改 `winSize`。)这将产生更好的标定结果。之后我们将有效的输入结果添加到 *imagePoints* 向量中,将所有方程收集到一个容器里。最后,出于可视化反馈的目的,我们将使用 cv::findChessboardCorners 函数在输入图像上绘制找到的点。 1. **向用户显示状态和结果,以及应用程序的命令行控制** 这部分在图像上显示文本输出。 如果我们运行了标定并得到了带有畸变系数的相机矩阵,就可以使用 cv::undistort 函数来校正图像: 然后我们显示图像并等待输入按键:如果是 *u*,我们切换去畸变;如果是 *g*,我们重新开始检测过程;最后对于 *ESC* 键,我们退出应用程序: 1. **同时对图像显示去畸变效果** 当你使用图像列表时,无法在循环内部去除畸变。因此,你必须在循环之后执行此操作。借此机会,我将展开说明 cv::undistort 函数,它实际上是先调用 cv::initUndistortRectifyMap 查找变换矩阵,然后使用 cv::remap 函数执行变换。因为成功标定后映射计算只需做一次,使用这种展开形式可以加速你的应用程序: ## 标定与保存 由于标定对每台相机只需做一次,因此成功标定后保存结果是很有意义的。这样以后你就可以直接将这些值加载到你的程序中。为此,我们先进行标定,如果成功,就将结果保存到一个 OpenCV 风格的 XML 或 YAML 文件中,具体取决于你在配置文件中给出的扩展名。 因此在第一个函数中,我们只是把这两个过程拆分开来。因为我们要保存许多标定变量,所以我们在这里创建这些变量,并将它们都传递给标定和保存函数。同样,我不会展示保存部分,因为它与标定关系不大。请查阅源文件以了解如何保存以及保存了什么: 我们借助 cv::calibrateCameraRO 函数来进行标定。它有以下参数: - 物体点(object points)。这是一个 *Point3f* 向量组成的向量,为每个输入图像描述图案应当是什么样子。如果我们使用平面图案(如棋盘),那么可以简单地把所有 Z 坐标设为零。这是这些重要点所在的点的集合。因为我们对所有输入图像使用同一个图案,所以可以只计算一次,然后将其复制给所有其他输入视图。我们用 *calcBoardCornerPositions* 函数计算角点: 然后这样复制它: ```cpp 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]); ``` **注意:** 如果你的标定板不准确、未测量、是粗略的平面目标(用普通打印机打印在纸上的棋盘图案是最方便的标定目标,但大多数都不够精确),可以使用 [strobl2011iccv] 中的方法来大幅提高所估计相机内参的精度。当提供命令行参数 `-d=<number>` 时,将启用这种新的标定方法。在上面的代码片段中,`grid_width` 实际上就是由 `-d=<number>` 设置的值。它是图案网格点中左上角 (0, 0, 0) 与右上角 (s.squareSize*(s.boardSize.width-1), 0, 0) 角点之间的实测距离。应当用直尺或游标卡尺精确测量。标定之后,newObjPoints 将更新为精化后的物体点三维坐标。 - 图像点(image points)。这是一个 *Point2f* 向量组成的向量,为每个输入图像包含重要点的坐标(棋盘的角点或圆点图案中圆点的圆心)。我们已经从 cv::findChessboardCorners 或 cv::findCirclesGrid 函数中收集了这些点。只需将其传入即可。 - 从相机、视频文件或图像获取的图像尺寸。 - 要固定的物体点的索引。我们将其设为 -1 以请求标准标定方法。如果要使用新的物体释放(object-releasing)方法,则将其设为标定板网格右上角角点的索引。详细解释参见 cv::calibrateCameraRO。 ```cpp int iFixedPoint = -1; if (release_object) iFixedPoint = s.boardSize.width - 1; - 相机矩阵。如果我们使用了固定纵横比选项,就需要设置 $f_x$: - 畸变系数矩阵。用零初始化。 ``` distCoeffs = Mat::zeros(8, 1, CV_64F); ``` - 对于所有视图,该函数都会计算旋转向量和平移向量,它们将物体点(在模型坐标系中给出)变换为图像点(在世界坐标系中给出)。第 7 和第 8 个参数是矩阵的输出向量,其中第 i 个位置存放第 i 个物体点到第 i 个图像点的旋转向量和平移向量。 - 更新后的标定图案点输出向量。标准标定方法会忽略此参数。 - 最后一个参数是标志(flag)。你需要在此指定一些选项,例如固定焦距的纵横比、假设切向畸变为零或固定主点。这里我们使用 CALIB_USE_LU 以获得更快的标定速度。 rms = calibrateCameraRO(objectPoints, imagePoints, imageSize, iFixedPoint, cameraMatrix, distCoeffs, rvecs, tvecs, newObjPoints, s.flag | CALIB_USE_LU); - 该函数返回平均重投影误差。这个数字能很好地估计所找到参数的精度。它应当尽可能接近零。给定内参、畸变、旋转和平移矩阵,我们可以先用 cv::projectPoints 将物体点变换为图像点,从而计算单个视图的误差。然后我们计算变换得到的结果与角点/圆点查找算法得到结果之间的绝对范数。为了求平均误差,我们对所有标定图像计算得到的误差取算术平均值。 ## 结果 假设有[这个输入棋盘图案](https://raw.githubusercontent.com/opencv/opencv/4.x/doc/pattern.png),其尺寸为 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> ``` ![fileListImage.jpg](/img/opencv/fileListImage.jpg) ![fileListImageUnDist.jpg](/img/opencv/fileListImageUnDist.jpg) ![asymetricalPattern.jpg](/img/opencv/asymetricalPattern.jpg) 然后将 `images/CameraCalibration/VID5/VID5.XML` 作为输入传给配置文件。以下是应用程序运行期间找到的一个棋盘图案: 应用去畸变后,我们得到: 同样适用于[这个非对称圆点图案](https://raw.githubusercontent.com/opencv/opencv/4.x/doc/acircles_pattern.png),只需将输入宽度设为 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](https://www.youtube.com/watch?v=ViPN810E0SU)上观看一个运行实例。 [视频演示](https://www.youtube.com/watch?v=ViPN810E0SU)