【转载】[OpenCV实战]50 用OpenCV制作低成本立体相机

881 阅读12分钟

版权声明:本文为CSDN博主「落痕的寒假」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:blog.csdn.net/LuohenYJ/ar…

本文主要讲述利用 OpenCV 制作低成本立体相机以及如何使用 OpenCV 创建 3D 视频,准确来说是模仿双目立体相机,我们通常说立体相机一般是指双目立体相机,就是带两个摄像头的那种(目就是指眼睛,双目就是两只眼睛),这种双目摄像机模仿人的视觉,所以应用很广泛(主要是工业机器人视觉)。双目摄像机也广泛应用于无人驾驶,比如特斯拉、图森未来,小鹏汽车在自家的无人驾驶汽车上都安载了立体相机,双目和多目的都有。另外双目视觉加上深度学习还蛮好水论文的。本文主要说的是低成本,实际上没人这样干,有专门的双目立体相机,已经非常成熟了,但是原理大差不差。专门的工业级双目立体相机价格大概 5000 多左右,家用的或者用来玩的最低 1000 能买到,有些双目立体摄像机还自带深度学习算法,所以本文看看就好

本文主要参考 Making A Low-Cost Stereo Camera Using OpenCV双目视觉之空间坐标计算。 本文所有代码见:

1 相关介绍

1.1 背景

我们通常都使用下图所示的红青色 3D 眼镜来体验 3D 效果。它是如何工作的?当屏幕只是平面时,我们如何体验 3D 效果?这些是使用立体摄像机设置捕获的。

image.png

[OpenCV实战]49 对极几何与立体视觉初探文章中,我们了解了立体相机以及如何将其用于帮助计算机感知深度。在本文中,我们学习如何创建自己的立体相机,并了解如何将其用于创建 3D 视频。具体来说包括立体相机的创建,立体相机的校准以及自定义 3D 视频,主要是讲立体相机的校准。

1.2 创建双目立体相机

立体摄像头设置通常包含两个相同的摄像头,它们以固定的距离隔开。工业级标准立体摄像机设置使用一对相同的摄像机。说白了这里你需要一个自己的立体相机(双目立体相机),你可以买,也可以自己创造一个。但是保持两个摄像机严格固定和平行。关于立体相机型号可以看看 3D Camera Survey 。关于立体相机的原理见 深度相机原理揭秘–双目立体视觉

如果是自己弄台双目立体相机,也不是不行。反正很多人也这样干。你可以看用文章DIY 3D立体相机的帖子整理用摄像头来自己搭建双目立体相机。或者也可以通过两部手机搭建双目立体相机,详情见低成本DIY拍摄3D照片的相机和如何用两台傻瓜相机制作自己的3D相机。反正最终弄出来的结果如下图所示:

image.png

如果是高精度双目视觉应用的话,stereolabs 家的zed系列相机,毕竟工业级双目相机的搭建技术含量很高,不是随便就能弄好的,国内在这一块相对比较薄弱。下图就是 zed 系列某部立体相机的真容,4000 多一点,百度的图片。

当你有了立体相机,你需要进行立体标定(stereo calibration)以搭建自己的视觉系统,为什么要这要做后面会说到。本文只是简单的讲一讲相关原理,会提供图像,自己也不需要有双目相机,看看就好。至于更详细的双目视觉系统搭建可以看看文章一起搭建双目视觉系统

2 相机立体标定与校正

2.1 立体标定重要性

为了理解立体标定的重要性,我们提供了没有立体标定的图像,如下图所示,然后生成视差图。

image.png

生成的视差图如下图所示:

image.png

我们观察到,使用未校准的立体相机设置生成的视差图 非常嘈杂且不准确。为什么会这样?根据[OpenCV实战]49 对极几何与立体视觉初探里提到的,相应的关键点应具有相等的 Y 坐标,以简化点对应搜索。如下图所示,当我们在几个对应点之间绘制匹配线时,我们观察到这些线不是完全水平的。

image.png

下图显示了相应的关键点具有相等的 Y 坐标的立体图像,以及使用这些图像生成的视差图。我们观察到,与前一张相比,现在的视差图噪声更低

image.png

仅当摄像机平行时才可能出现上图这种情况。但是实际过程中,两部摄像机不可能完全平行,或者我们想要达到基本平行需要耗费大量的时间来调整摄像机。所以我们可以用另外一种方式,无需物理调整摄像机,而是在软件方面体标定并校正摄像机。下图说明了立体标定与校准的过程。如下图所示,这个想法是在平行于穿过光学中心的线的公共平面上重新投影两个图像。这样可以确保相应的点具有相同的Y坐标,并且仅通过水平平移进行关联。具体原理见双目立体标定。这些东西不是一句两句就能说清楚,只要有这个东西就好了。

image.png

2.2 相机立体标定和校正的步骤

整个过程如下所示:

  1. 使用标准 OpenCV 校正方法校准单个相机 。
  2. 确定两个摄像机之间的转换关系。
  3. 使用前面步骤中获得的参数和 OpenCV 的 stereoCalibrate 方法,应用于两个图像的变换以进行立体校正。
  4. 使用 OpenCV 的 initUndistortRectifyMap 方法找到未失真和已校正图像对所需的映射。
  5. 将此映射应用于原始图像,以校正未失真图像。

这一些步骤就是相机标定中的张正友标定法,数学原理详解相机标定之张正友标定法数学原理详解。这里面会涉及到一系列的 OpenCV 函数,详细使用见双目视觉之空间坐标计算。总体相机立体标定过程由图表示如下所示:

image.png

我的备注:以下实例代码在 samples/cpp/calibration.cpp 【注意不是 3calibration.cpp】和 samples/python/calibrate.py ,对应运行所需的数据在 samples/data 目录下 !

2.2.1 单个相机校正

在执行立体标定之前,我们会对两个相机分别进行标定。但是,如果 OpenCV 的 stereoCalibrate() 方法可以对两个相机中的每一个进行校正,为什么还要分别标定相机呢

这是因为由于要计算的参数很多(较大的参数空间),并且在诸如角点检测和近似点之类的步骤中累积了错误。这增加了迭代方法偏离正确解决方案的机会。因此,我们分别计算摄像机参数,并仅使用 stereoCalibrate() 方法来查找立体相机之间的对应关系。

代码如下,主要讲的是提取角点,然后去除畸形点,最后分别对每个相机进行校正。

所谓角点检测如下图所示,左图是原图,右图是角点检测的可视化结果,我们所需要提取就是角点,然后进行相机校正。

image.png

C++

// Creating vector to store vectors of 3D points for each checkerboard image
// 创建 vector 以存储每个棋盘图像的 3D 点矢量
std::vector<std::vector<cv::Point3f> > objpoints;

// Creating vector to store vectors of 2D points for each checkerboard image
// 创建 vector 以存储每个棋盘图像的 2D 点矢量
std::vector<std::vector<cv::Point2f> > imgpointsL, imgpointsR;

// Defining the world coordinates for 3D points
// 定义三维点的世界坐标
std::vector<cv::Point3f> objp;
// 初始化点,cv::Point3f(j, i, 0) 保存的是 x,y,z 坐标。
for (int i{ 0 }; i < CHECKERBOARD[1]; i++)
{
    for (int j{ 0 }; j < CHECKERBOARD[0]; j++)
    {
        objp.push_back(cv::Point3f(j, i, 0));
    }
}

// Extracting path of individual image stored in a given directory
// 提取存储在给定目录中的单个图像的路径
std::vector<cv::String> imagesL, imagesR;
// Path of the folder containing checkerboard images
// 包含棋盘图像的文件夹的路径
// pathL 和 pathR 分别为两个摄像头在多个时刻拍摄的图像
std::string pathL = "./data/stereoL/*.png";
std::string pathR = "./data/stereoR/*.png";

// 提取两个文件夹 所有的图像
cv::glob(pathL, imagesL);
cv::glob(pathR, imagesR);

cv::Mat frameL, frameR, grayL, grayR;
// vector to store the pixel coordinates of detected checker board corners
// 用于存储检测到的棋盘角点的像素坐标的向量
std::vector<cv::Point2f> corner_ptsL, corner_ptsR;
bool successL, successR;

// Looping over all the images in the directory
// 遍历目录中的所有图像
for (int i{ 0 }; i < imagesL.size(); i++)
{
    // 提取同一时刻分别用两个摄像头拍到的照片
    frameL = cv::imread(imagesL[i]);
    cv::cvtColor(frameL, grayL, cv::COLOR_BGR2GRAY);

    frameR = cv::imread(imagesR[i]);
    cv::cvtColor(frameR, grayR, cv::COLOR_BGR2GRAY);

    // Finding checker board corners
    // 寻找棋盘图的内角点位置
    // If desired number of corners are found in the image then success = true
    // 如果在图像中找到所需的角数,则 success=true
    // 具体函数使用介绍见 https://blog.csdn.net/h532600610/article/details/51800488
    successL = cv::findChessboardCorners(
        grayL,
        cv::Size(CHECKERBOARD[0], CHECKERBOARD[1]),
        corner_ptsL);
    // cv::CALIB_CB_ADAPTIVE_THRESH | cv::CALIB_CB_FAST_CHECK | cv::CALIB_CB_NORMALIZE_IMAGE);

    successR = cv::findChessboardCorners(
        grayR,
        cv::Size(CHECKERBOARD[0], CHECKERBOARD[1]),
        corner_ptsR);
    // cv::CALIB_CB_ADAPTIVE_THRESH | cv::CALIB_CB_FAST_CHECK | cv::CALIB_CB_NORMALIZE_IMAGE);

    // 如果检测到所需的角点个数,则细化像素坐标并将其显示在棋盘格图像上
    if ((successL) && (successR))
    {
        // TermCriteria 定义迭代算法终止条件的类
        // 具体使用见 https://www.jianshu.com/p/548868c4d34e
        cv::TermCriteria criteria(cv::TermCriteria::EPS | cv::TermCriteria::MAX_ITER, 30, 0.001);

        // refining pixel coordinates for given 2d points.
        // 细化给定2D点的像素坐标,cornerSubPix 用于亚像素角点检测
        // 具体参数见 https://blog.csdn.net/guduruyu/article/details/69537083
        cv::cornerSubPix(grayL, corner_ptsL, cv::Size(11, 11), cv::Size(-1, -1), criteria);
        cv::cornerSubPix(grayR, corner_ptsR, cv::Size(11, 11), cv::Size(-1, -1), criteria);

        // Displaying the detected corner points on the checker board
        // drawChessboardCorners 用于绘制棋盘格角点的函数
        cv::drawChessboardCorners(frameL, cv::Size(CHECKERBOARD[0], CHECKERBOARD[1]), corner_ptsL, successL);
        cv::drawChessboardCorners(frameR, cv::Size(CHECKERBOARD[0], CHECKERBOARD[1]), corner_ptsR, successR);

        // 保存数据以供后续使用
        // 保存三维点数据
        objpoints.push_back(objp);
        // 保存角点信息
        imgpointsL.push_back(corner_ptsL);
        imgpointsR.push_back(corner_ptsR);
    }

    //cv::imshow("ImageL", frameL);
    //cv::imshow("ImageR", frameR);
    //cv::waitKey(0);
}

// 关闭所有窗口
cv::destroyAllWindows();

// 通过传递已知三维点(objpoints)的值和检测到的角点(imgpoints)的相应像素坐标来执行相机校准
// mtxL,mtxR 为内参数矩阵, distL 和 distR 为畸变矩阵
// R_L 和 R_R 为旋转向量,T_L 和 T_R 为位移向量
cv::Mat mtxL, distL, R_L, T_L;
cv::Mat mtxR, distR, R_R, T_R;

cv::Mat new_mtxL, new_mtxR;

// Calibrating left camera
// 校正左边相机

// 相机标定函数
// 函数使用见 https://blog.csdn.net/u011574296/article/details/73823569
cv::calibrateCamera(objpoints,
    imgpointsL,
    grayL.size(),
    mtxL,
    distL,
    R_L,
    T_L);

// 去畸变,优化相机内参,这一步可选
// getOptimalNewCameraMatrix 函数使用见 https://www.jianshu.com/p/df78749b4318
new_mtxL = cv::getOptimalNewCameraMatrix(mtxL,
    distL,
    grayL.size(),
    1,
    grayL.size(),
    0);

// Calibrating right camera
// 校正右边相机
cv::calibrateCamera(objpoints,
    imgpointsR,
    grayR.size(),
    mtxR,
    distR,
    R_R,
    T_R);

new_mtxR = cv::getOptimalNewCameraMatrix(mtxR,
    distR,
    grayR.size(),
    1,
    grayR.size(),
    0);

Python(略)

原文中有,这里为了节约篇幅省略了

2.2.2 使用固定的固有参数执行立体标定

校准摄像机后,我们将它们传递给 stereoCalibrate 方法,我们还传递两个相机捕获的 3D 点和相应的 2D 像素坐标,用于计算相机间的转换关系。这样实现了立体标定,代码如下:

C++

// Here we fix the intrinsic camara matrixes so that only Rot, Trns, Emat and Fmat
// are calculated. Hence intrinsic parameters are the same.
// 在这里,我们修正了固有的 camara 矩阵,以便只计算 Rot、Trns、Emat 和 Fmat。因此内在参数是相同的。
cv::Mat Rot, Trns, Emat, Fmat;

int flag = 0;
flag |= cv::CALIB_FIX_INTRINSIC;

// This step is performed to transformation between the two cameras and calculate Essential and Fundamenatl matrix
// 同时标定两个摄像头,函数介绍见 https://www.cnblogs.com/zyly/p/9373991.html
cv::stereoCalibrate(objpoints,
    imgpointsL,
    imgpointsR,
    new_mtxL,
    distL,
    new_mtxR,
    distR,
    grayR.size(),
    Rot,
    Trns,
    Emat,
    Fmat,
    flag,
    cv::TermCriteria(cv::TermCriteria::MAX_ITER + cv::TermCriteria::EPS, 30, 1e-6));

2.2.3 立体校正

使用相机间的内外参数,和转换关系参数,就能够进行立体校正。立体校正运用旋转使两个相机图像面都在同一平面上,stereoRectify 方法还能返回新坐标空间中的投影矩阵。关于投影矩阵这是个相机几何学的基础概念,具体见Projection Matrices。代码如下:

C++

cv::Mat rect_l, rect_r, proj_mat_l, proj_mat_r, Q;

// Once we know the transformation between the two cameras we can perform stereo rectification
// 一旦我们知道两个摄像机之间的变换,我们就可以进行立体校正
// stereoRectify 同时校正两个摄像机,函数介绍见 https://www.cnblogs.com/zyly/p/9373991.html
cv::stereoRectify(new_mtxL,
    distL,
    new_mtxR,
    distR,
    grayR.size(),
    Rot,
    Trns,
    rect_l,
    rect_r,
    proj_mat_l,
    proj_mat_r,
    Q,
    1);

2.2.4 获得未失真的校正立体图像对所需的映射

由于我们假设摄像机是固定的,因此无需再次计算变换。因此,我们计算将立体图像对转换为未失真的校正立体图像对的映射,并将其存储以备将来使用。

C++

// Use the rotation matrixes for stereo rectification and camera intrinsics for undistorting the image
// Compute the rectification map (mapping between the original image pixels and
// their transformed values after applying rectification and undistortion) for left and right camera frames
// 根据相机单目标定得到的内参以及 stereoRectify 计算出来的值来计算畸变矫正和立体校正的映射变换矩阵
cv::Mat Left_Stereo_Map1, Left_Stereo_Map2;
cv::Mat Right_Stereo_Map1, Right_Stereo_Map2;

// 函数介绍见 https://www.cnblogs.com/zyly/p/9373991.html
cv::initUndistortRectifyMap(new_mtxL,
    distL,
    rect_l,
    proj_mat_l,
    grayR.size(),
    CV_16SC2,
    Left_Stereo_Map1,
    Left_Stereo_Map2);

cv::initUndistortRectifyMap(new_mtxR,
    distR,
    rect_r,
    proj_mat_r,
    grayR.size(),
    CV_16SC2,
    Right_Stereo_Map1,
    Right_Stereo_Map2);

// 保存校正信息
cv::FileStorage cv_file = cv::FileStorage("data/params_cpp.xml", cv::FileStorage::WRITE);
cv_file.write("Left_Stereo_Map_x", Left_Stereo_Map1);
cv_file.write("Left_Stereo_Map_y", Left_Stereo_Map2);
cv_file.write("Right_Stereo_Map_x", Right_Stereo_Map1);
cv_file.write("Right_Stereo_Map_y", Right_Stereo_Map2);
cv_file.release();

3 自定义3D视频

3.1 3D 眼镜如何工作?

校准完我们的 DIY 立体相机后,我们就可以创建 3D 视频了。但是,在我们了解如何制作 3D 视频之前,必须了解 3D 眼镜的工作原理。我们使用双目视觉系统感知世界。我们的眼睛处于横向不同的位置。因此,它们捕获的信息不同。那么我们的左眼和右眼捕获的信息之间有什么区别? 例如将你的手机放在你的面前正中处,闭上左眼只用右眼观察手机和闭上右眼只用左眼观察手机,你会发现手机的相对水平位置是不同的。这种位置差异称为水平视差。现在,将手机靠近您并重复相同的实验。您现在观察到了哪些变化?与对象相对应的水平视差增加。因此,物体的视差越高,它越近。这是我们如何使用我们的双眼视觉系统来感知深度的原因。 我们可以通过使用称为体视学 stereoscopy 的方法,分别向每只眼睛人工呈现两个不同的图像来模拟这种视。最初,对于 3D 电影,人们是通过使用红色和青色的滤镜对每只眼睛的图像进行编码来实现的。他们使用了红青色 3D 眼镜,以确保两个图像均到达预期的眼睛。这创造了深度的幻觉。用这种方法产生的立体效果称为立体 3D。因此,这些图像被称为立体影片,并且眼镜被称为立体 3D 眼镜。

3.2 创建自定义 3D 视频

我们了解了当使用立体 3D 眼镜观看时,立体图像对如何转换为立体 3D 图像以产生深度的幻觉。我们可以使用立体相机设置来捕获多张相机图像,并为每个相机图像对创建一个立体图像。然后,我们将所有连续的立体图像保存为视频,这就是我们制作 3D 视频的方式!

要注意所使用的两个摄像头必须经过章节 2 的摄像头校正,本章节所生产的视频必须使用 3D 眼镜~~~

代码如下:

C++

#include <opencv2/opencv.hpp>
#include <opencv2/calib3d/calib3d.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <stdio.h>
#include <iostream>

int main()
{
	// Check for left and right camera IDs
	// 左右视频 id
	std::string CamL_id{ "data/stereoL.mp4" }, CamR_id{ "data/stereoR.mp4" };

	// 打开视频
	cv::VideoCapture camL(CamL_id), camR(CamR_id);

	cv::Mat Left_Stereo_Map1, Left_Stereo_Map2;
	cv::Mat Right_Stereo_Map1, Right_Stereo_Map2;

	// 打开校正文件
	cv::FileStorage cv_file = cv::FileStorage("data/params_cpp.xml", cv::FileStorage::READ);
	cv_file["Left_Stereo_Map_x"] >> Left_Stereo_Map1;
	cv_file["Left_Stereo_Map_y"] >> Left_Stereo_Map2;
	cv_file["Right_Stereo_Map_x"] >> Right_Stereo_Map1;
	cv_file["Right_Stereo_Map_y"] >> Right_Stereo_Map2;
	cv_file.release();

	// 检测视频是否存在
	// Check if left camera is attched
	if (!camL.isOpened())
	{
		std::cout << "Could not open camera with index : " << CamL_id << std::endl;
		return -1;
	}

	// Check if right camera is attached
	if (!camL.isOpened())
	{
		std::cout << "Could not open camera with index : " << CamL_id << std::endl;
		return -1;
	}

	cv::Mat frameL, frameR;

	for (size_t i{ 0 }; i < 100000; i++)
	{
		camL >> frameL;
		camR >> frameR;

		cv::Mat Left_nice, Right_nice;

		// 简单重映射图像
		// 函数使用见 https://blog.csdn.net/qq_42887760/article/details/86513649
		cv::remap(frameL,
			Left_nice,
			Left_Stereo_Map1,
			Left_Stereo_Map2,
			cv::INTER_LANCZOS4,
			cv::BORDER_CONSTANT,
			0);

		cv::remap(frameR,
			Right_nice,
			Right_Stereo_Map1,
			Right_Stereo_Map2,
			cv::INTER_LANCZOS4,
			cv::BORDER_CONSTANT,
			0);

		cv::Mat Left_nice_split[3], Right_nice_split[3];

		std::vector<cv::Mat> Anaglyph_channels;
		// 分离通道
		cv::split(Left_nice, Left_nice_split);
		cv::split(Right_nice, Right_nice_split);

		Anaglyph_channels.push_back(Left_nice_split[0]);
		Anaglyph_channels.push_back(Left_nice_split[1]);
		Anaglyph_channels.push_back(Right_nice_split[2]);

		cv::Mat Anaglyph_img;

		// 组合为 3D 图像
		cv::merge(Anaglyph_channels, Anaglyph_img);

		cv::imshow("Anaglyph image", Anaglyph_img);
		cv::waitKey(1);
	}

	return 0;
}

4 参考