【小白深度教程 1.7】手把手教你使用 OpenCV 制作低成本双目立体相机(Python、C++ 代码)

使用 OpenCV 制作低成本立体相机

在这篇文章中,我们将尝试如何创建一个自定义的低成本立体相机(使用一对网络摄像头),并使用 OpenCV 创建 3D 视频。

在这里插入图片描述

我们都喜欢看 3D 电影和视频,如上图所示。要体验 3D 效果,你需要如图所示的 3D 眼镜。它是如何工作的呢?

在之前的文章中,我们学习了立体相机及其如何帮助计算机感知深度。

在这篇文章中,我们将学习如何创建自己的立体相机,并了解如何使用它来创建 3D 视频。

1. 创建立体相机的步骤

一个立体相机通常包含两个固定距离放置的相同相机。工业级标准立体相机通常使用一对相同的相机。

要在家制作一个立体相机,我们需要以下材料:

  1. 两个 USB 网络摄像头(最好是相同型号)。
  2. 用于固定相机的刚性底座(木材、纸板、PVC 泡沫板)。
  3. 夹具或胶带。

可以使用不同的组件来创建立体相机,但基本要求是保持相机固定且平行。

在这里插入图片描述

许多人已经创建并分享了他们的 DIY 立体相机,如上图左侧的图像所示,或者参考 这篇文章

一旦我们固定了相机并确保它们正确对齐,是否就完成了呢?我们是否准备好生成视差图和 3D 视频了呢?

2. 立体校准和校正的重要性

为了理解立体校准和校正的重要性,我们尝试使用未经过校准或校正的立体相机捕获的图像生成视差图,结果如下图所示:

在这里插入图片描述
在这里插入图片描述

我们观察到,未校准的立体相机生成的视差图非常嘈杂且不准确。为什么会这样呢?

根据之前的文章,相应的关键点应该具有相同的 Y 坐标,以简化点对应的搜索。

在下图中,当我们绘制几个对应点之间的匹配线时,我们观察到这些线并不完全水平。

在这里插入图片描述

我们还观察到,相应点的 Y 坐标不相等。

下图显示了一对具有点对应关系的立体图像,以及使用这些图像生成的视差图。

我们观察到,与之前相比,视差图现在更少嘈杂。在这种情况下,相应的关键点具有相同的 Y 坐标。

只有在相机平行时才会出现这种情况。这是双视图几何的一种特殊情况,其中图像是平行的,并且仅通过水平平移相关。

这是必要的,因为用于生成视差图的方法仅在水平上搜索点对应关系。

在这里插入图片描述

因此,我们需要做的就是对齐我们的相机并使它们完全平行。那么我们是通过反复试验手动调整相机吗?

但是,手动调整相机以获得清晰的视差图需要很长时间。此外,每次设置被打乱并且相机移位时,我们都必须重复这个过程。

这是非常费时间的,并不是理想的解决方案。

我们不再物理调整相机,而是在软件端进行,用算法代替人工。

我们使用一种称为 立体图像校正 的方法。

下图解释了立体校正的过程。其思想是将两个图像重新投影到与光学中心线平行的公共平面上。这确保了相应的点具有相同的 Y 坐标,并且仅通过水平平移相关。

在这里插入图片描述

3. 立体校准和校正的步骤

我们还知道,相机捕获的图像存在镜头畸变。因此,除了立体校正之外,去畸变也是必不可少的。因此,整体过程如下:

  1. 使用标准 OpenCV 校准方法单独校准每个相机,详细内容请见 相机校准文章
  2. 确定用于立体相机设置的两台相机之间的变换。
  3. 使用前面步骤中获得的参数和 stereoCalibrate 方法,我们确定应用于两幅图像的变换,以实现立体校正。
  4. 最后,使用 initUndistortRectifyMap 方法获得用于查找未失真和校正的立体图像对的映射。
  5. 将该映射应用于原始图像,以获得未失真和校正的立体图像对。

我们通过拍摄校准图案的图像来执行这些步骤。

3.1. 步骤 1:独立校准立体设置的左右摄像头

在这里插入图片描述

我们在进行立体校准之前对两台相机分别进行校准。

但是,如果 stereoCalibrate() 方法也可以对每台相机进行校准,为什么还需要单独校准相机呢?

因为需要计算的参数很多(参数空间大),并且在角点检测和将点近似为整数等步骤中会累积误差。

这增加了迭代方法偏离正确解的可能性。

因此,我们单独计算相机参数,并仅使用 stereoCalibrate() 方法来查找立体相机对之间的变换、基本矩阵和本质矩阵。

但是,算法如何知道要跳过单独的相机校准呢?

为此,我们设置标志 CALIB_FIX_INTRINSIC 并将其传递给方法。

Python:

# Set the path to the images captured by the left and right cameras
pathL = "./data/stereoL/"
pathR = "./data/stereoR/"
 
# Termination criteria for refining the detected corners
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
 
 
objp = np.zeros((9*6,3), np.float32)
objp[:,:2] = np.mgrid[0:9,0:6].T.reshape(-1,2)
 
img_ptsL = []
img_ptsR = []
obj_pts = []
 
for i in tqdm(range(1,12)):
  imgL = cv2.imread(pathL+"img%d.png"%i)
  imgR = cv2.imread(pathR+"img%d.png"%i)
  imgL_gray = cv2.imread(pathL+"img%d.png"%i,0)
  imgR_gray = cv2.imread(pathR+"img%d.png"%i,0)
 
  outputL = imgL.copy()
  outputR = imgR.copy()
 
  retR, cornersR =  cv2.findChessboardCorners(outputR,(9,6),None)
  retL, cornersL = cv2.findChessboardCorners(outputL,(9,6),None)
 
  if retR and retL:
    obj_pts.append(objp)
    cv2.cornerSubPix(imgR_gray,cornersR,(11,11),(-1,-1),criteria)
    cv2.cornerSubPix(imgL_gray,cornersL,(11,11),(-1,-1),criteria)
    cv2.drawChessboardCorners(outputR,(9,6),cornersR,retR)
    cv2.drawChessboardCorners(outputL,(9,6),cornersL,retL)
    cv2.imshow('cornersR',outputR)
    cv2.imshow('cornersL',outputL)
    cv2.waitKey(0)
 
    img_ptsL.append(cornersL)
    img_ptsR.append(cornersR)
 
 
# Calibrating left camera
retL, mtxL, distL, rvecsL, tvecsL = cv2.calibrateCamera(obj_pts,img_ptsL,imgL_gray.shape[::-1],None,None)
hL,wL= imgL_gray.shape[:2]
new_mtxL, roiL= cv2.getOptimalNewCameraMatrix(mtxL,distL,(wL,hL),1,(wL,hL))
 
# Calibrating right camera
retR, mtxR, distR, rvecsR, tvecsR = cv2.calibrateCamera(obj_pts,img_ptsR,imgR_gray.shape[::-1],None,None)
hR,wR= imgR_gray.shape[:2]
new_mtxR, roiR= cv2.getOptimalNewCameraMatrix(mtxR,distR,(wR,hR),1,(wR,hR))

C++:

// Defining the dimensions of checkerboard
int CHECKERBOARD[2]{6,9}; 
 
// Creating vector to store vectors of 3D points for each checkerboard image
std::vector<std::vector<cv::Point3f> > objpoints;
 
// Creating vector to store vectors of 2D points for each checkerboard image
std::vector<std::vector<cv::Point2f> > imgpointsL, imgpointsR;
 
// Defining the world coordinates for 3D points
std::vector<cv::Point3f> objp;
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
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  
  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 desired number of corner are detected,
    * we refine the pixel coordinates and display 
    * them on the images of checker board
  */
  if((successL) && (successR))
  {
    cv::TermCriteria criteria(cv::TermCriteria::EPS | cv::TermCriteria::MAX_ITER, 30, 0.001);
 
    // refining pixel coordinates for given 2d points.
    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
    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();
 
cv::Mat mtxL,distL,R_L,T_L;
cv::Mat mtxR,distR,R_R,T_R;
cv::Mat Rot, Trns, Emat, Fmat;
cv::Mat new_mtxL, new_mtxR;
 
// Calibrating left camera
cv::calibrateCamera(objpoints,
                    imgpointsL,
                    grayL.size(),
                    mtxL,
                    distL,
                    R_L,
                    T_L);
 
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);

3.2. 步骤 2:使用固定的内在参数进行立体校准

由于相机已经校准,我们将它们传递给 stereoCalibrate() 方法并设置 CALIB_FIX_INTRINSIC 标志。我们还传递了 3D 点和在两幅图像中捕获的相应 2D 像素坐标。

该方法计算两台相机之间的旋转和平移,以及基本矩阵和本质矩阵。

Python:

flags = 0
flags |= cv2.CALIB_FIX_INTRINSIC
# Here we fix the intrinsic camara matrixes so that only Rot, Trns, Emat and Fmat are calculated.
# Hence intrinsic parameters are the same 
 
criteria_stereo= (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
 
 
# This step is performed to transformation between the two cameras and calculate Essential and Fundamenatl matrix
retS, new_mtxL, distL, new_mtxR, distR, Rot, Trns, Emat, Fmat = cv2.stereoCalibrate(obj_pts, img_ptsL, img_ptsR, new_mtxL, distL, new_mtxR, distR, imgL_gray.shape[::-1], criteria_stereo, flags)

C++:

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
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));

3.3. 步骤 3:立体校正

使用相机的内在参数以及相机之间的旋转和平移,我们现在可以应用立体校正。

立体校正应用旋转,使两个相机的图像平面位于同一平面上。

除了旋转矩阵外,stereoRectify 方法还返回在新坐标空间中的投影矩阵。

由于我们假设相机是固定的,因此无需再次计算变换。

因此,我们计算将立体图像对转换为未失真和校正的立体图像对的映射,并将其存储以备进一步使用。

Python:

rectify_scale= 1
rect_l, rect_r, proj_mat_l, proj_mat_r, Q, roiL, roiR= cv2.stereoRectify(new_mtxL, distL, new_mtxR, distR, imgL_gray.shape[::-1], Rot, Trns, rectify_scale,(0,0))

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
cv::stereoRectify(new_mtxL,
                  distL,
                  new_mtxR,
                  distR,
                  grayR.size(),
                  Rot,
                  Trns,
                  rect_l,
                  rect_r,
                  proj_mat_l,
                  proj_mat_r,
                  Q,
                  1);

4. 3D 眼镜是如何运作的?

一旦我们的 DIY 立体相机校准完成,我们就可以创建 3D 视频了。

但在了解如何制作 3D 视频之前,我们首先要了解 3D 眼镜是如何工作的。

我们使用双目视觉系统感知世界。我们的眼睛处于横向不同的位置,因此它们捕捉到的内容略有不同。

左眼和右眼捕捉到的信息有什么区别?

让我们做一个简单的实验:

伸出手臂,手中拿着任意物体。现在,闭上一只眼睛看物体。一秒钟后,用另一只眼睛重复这个动作,并继续交替。你能观察到两只眼睛看到的有什么区别吗?

主要区别在于物体的相对水平位置。这些位置差异被称为 水平视差

在之前的文章中,我们使用一对立体图像计算了视差图。

现在,将物体移近你,并重复同样的实验。你现在观察到什么变化?

与物体对应的水平视差增加了。因此,视差越大,物体越近。

这就是我们如何使用 立体视觉 通过我们的双目视觉系统感知深度的。

我们可以通过一种称为 立体视觉 的方法将两个不同的图像分别呈现给每只眼睛,从而模拟出这种视差。

最初,在 3D 电影中,人们通过使用红色和青色滤镜对每只眼睛的图像进行编码。他们使用红青 3D 眼镜确保两个图像分别到达预定的眼睛,从而创造出深度的错觉。

这种方法生成的立体效果称为 浮雕 3D 。因此,这些图像被称为浮雕图像,而眼镜被称为浮雕 3D 眼镜。

5. 创建自定义 3D 视频

我们已经理解了如何将立体图像对转换为浮雕图像,以便使用浮雕眼镜观看时产生深度错觉。那么我们如何捕捉这些立体图像呢?

我们使用我们的 DIY 立体相机设置捕捉立体图像,并为每对立体图像创建一个浮雕图像。

然后,我们将所有连续的浮雕图像保存为一个视频。

Python:

cv2.imshow("Left image before rectification", imgL)
cv2.imshow("Right image before rectification", imgR)
 
Left_nice= cv2.remap(imgL,Left_Stereo_Map[0],Left_Stereo_Map[1], cv2.INTER_LANCZOS4, cv2.BORDER_CONSTANT, 0)
Right_nice= cv2.remap(imgR,Right_Stereo_Map[0],Right_Stereo_Map[1], cv2.INTER_LANCZOS4, cv2.BORDER_CONSTANT, 0)
 
cv2.imshow("Left image after rectification", Left_nice)
cv2.imshow("Right image after rectification", Right_nice)
cv2.waitKey(0)
 
out = Right_nice.copy()
out[:,:,0] = Right_nice[:,:,0]
out[:,:,1] = Right_nice[:,:,1]
out[:,:,2] = Left_nice[:,:,2]
 
cv2.imshow("Output image", out)
cv2.waitKey(0)

C++:

cv::imshow("Left image before rectification",frameL);
cv::imshow("Right image before rectification",frameR);
 
cv::Mat Left_nice, Right_nice;
 
// Apply the calculated maps for rectification and undistortion 
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::imshow("Left image after rectification",Left_nice);
cv::imshow("Right image after rectification",Right_nice);
 
cv::waitKey(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(Right_nice_split[0]);
Anaglyph_channels.push_back(Right_nice_split[1]);
Anaglyph_channels.push_back(Left_nice_split[2]);
 
cv::Mat Anaglyph_img;
 
cv::merge(Anaglyph_channels, Anaglyph_img);
 
cv::imshow("Anaglyph image", Anaglyph_img);
cv::waitKey(0);
>