OpenCV CEO教你用OAK(二):立体视觉与深度估计
参考资料:地址
如果你是OAK相机新手用户,可以先看一下这个系列的第一篇文章:不认识OAK和DepthAI?OpenCV CEO亲自带你入门!这篇文章介绍了depthai API的安装,并介绍了一个基本的管道。
为一个立体视觉项目设置适当的硬件和软件的日子已经一去不复返了!由于有了OpenCV和Luxonis,你再也不用需要担心繁琐的初始设置。
本文我们将解释为什么我们需要两个相机来估计深度。然后我们将建立一个管道,使用OAK-D或OAK-D Lite计算深度。以下是你在本文中会遇到的一些关键术语:
视差(Disparity) | 基线(Baseline) | 校准(Calibration) | 立体校正(Stereo Rectification) |
立体视觉的几何学
立体视觉(stereo vision)是人类感知深度的方式之一。stereo这个词的意思是“两个”,我们从两个不同的角度看同一个场景来获得深度。人类还可通过其他几种方式感知深度,这点我们下次再讨论。
人类视觉系统启发了计算机立体视觉系统。有了OAK-D,连摄像头之间的距离都接近人眼的距离了!
在我们深入立体视觉之前,让我们回顾一下图像形成的理论。
坐标系统
图像是一个三维物体从现实世界到图像平面的二维投影,我们使用以下坐标系来描述一个成像装置:
- 世界坐标(3D,单位:米)
- 相机坐标(3D,单位:米)
- 图像平面坐标(2D,单位:像素)
将世界坐标映射到像素坐标可以告诉我们,物体离相机有多远。为了映射或关联这些坐标,我们需要知道相机的参数(例如,焦距)。
什么是相机校准?
获取镜头和图像传感器参数的过程称为相机校准。有两种参数:内部参数和外部参数。我们有详细的相机校准教程
我们可以用这些参数评估变换矩阵,它将真实坐标映射到像素坐标。查看我们关于图像形成的文章,了解更多细节。
深度是如何计算的?
举个例子,一个相机捕捉到了现实世界中的一个点Po的图像,其中Po(x, y, z)是该点在现实世界中的位置,P(u, v)在图像平面中的位置。
对于校准的系统,透视投影方程可以写成如下:
- fx,fy,u,v,Ox,Oy是以像素为单位的已知参数。
- 图像传感器中的像素可能不是正方形的,因此我们可能有两个不同的焦距fx 和fy。
- (Ox,Oy)是光轴与像平面相交的点。
因为我们只有两个方程,我们找不到三个未知变量:x,y,和z。为了找到他们,我们需要两个摄像头。另一个相同的摄像头位于立体系统中,如下图所示。假设两个摄像头都没有镜头失真。
- 相机中心之间的线称为基线。
- PL(uL,vL) 和PR(uR,vR) 是分别是点Po在左、右图像平面中的投影点。
这种设置为我们提供了以下四个等式:
求解这些方程,我们得到x, y,和z 如下所示:
这里,Z是指点距摄像头的深度,它与基线成正比。
什么是视差?
如果你仔细观察由两个黑白摄像头拍摄的两幅图像,你会发现图像并不完全相同。通过将两幅图像合并为一幅图像,每幅图像的贡献率为50%,就可以很容易地观察到这种差异。对应点的位置存在差异。这种差异被称为视差。
视差与深度成反比。
可以通过使用模板匹配或类似方法来找到第二图像中的对应点。高分辨率相机拍摄的图像有数百万个像素。因此,如果我们对整个图片进行处理,将是高度密集的过程。幸运的是,我们的相机是经过校准的,图像也是经过矫正的。因此,我们只需要沿着PL所在的行进行搜索。
深度估计的障碍
实际中的深度估计并不如想象中丝滑顺畅。正如我们上面所讨论的,我们用下面的假设导出了深度方程:
- 摄像头是水平的。
- 图像共面。
- 没有光学畸变。
然而,在立体视觉中很难达到理想的效果。相机很少对准,图像也不共面。这一点可以通过立体校正(stereo rectification)来解决。
立体校正是将左和右图像平面重新投影到平行于基线的共同平面上。我们将在下面讨论如何在管道中执行操作,借助于从校准中获得的摄像机参数来修正光学畸变。
深度估计管道
之前,我们展示了如何创建一个管道来访问OAK-D设备的黑白摄像头。我们将在此基础上进行改进,在管道中添加一个stereo depth节点,如下图所示。
stereo depth节点有以下输出:
- Rectified left
- Synced left
- Depth
- Disparity
- Rectified right
- Synced right
但是在我们的案例中,我们关心的是rectifiedLeft,disparity,和rectifiedRight。这些输出足以生成视差图并显示左右视图。所以话不多说,让我们从代码开始吧。
导入库
import cv2
import depthai as dai
import numpy as np
我们已经在第一篇文章中讨论了前两个功能。如果你在理解上有困难,请点击这里查看。
提取帧的函数
它从序列中查询帧,将其传输到主机,并将帧转换为NumPy数组。
def getFrame(queue):
# Get frame from queue
frame = queue.get()
# Convert frame to OpenCV format and return
return frame.getCvFrame()
选择黑白摄像头的函数
我们为管道创建一个节点,设置分辨率,然后将board socket设置为mono camera。
def getMonoCamera(pipeline, isLeft):
# Configure mono camera
mono = pipeline.createMonoCamera()
# Set Camera Resolution
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P)
if isLeft:
# Get left camera
mono.setBoardSocket(dai.CameraBoardSocket.LEFT)
else:
# Get right camera
mono.setBoardSocket(dai.CameraBoardSocket.RIGHT)
return mono
用于配置stereo pair的函数
这个函数生成一个stereo节点。它将左和右相机流作为输入,并生成上述输出。创建节点后,我们将left-right check设置为True,这样可以更好地处理遮挡问题。此标志告诉系统计算并合并L-R和R-L方向的差异,丢弃无效的视差值。你可以使用这个标志,当你将该标志设置为False时,会注意到有噪声的输出。最后,我们提供相机输出作为stereo节点的输入。
def getStereoPair(pipeline, monoLeft, monoRight):
# Configure stereo pair for depth estimation
stereo = pipeline.createStereoDepth()
# Checks occluded pixels and marks them as invalid
stereo.setLeftRightCheck(True)
# Configure left and right cameras to work as a stereo pair
monoLeft.out.link(stereo.left)
monoRight.out.link(stereo.right)
return stereo
鼠标回调函数
只是一个鼠标回调函数,定义为当我们点击一个点时记录这个点的像素坐标。
def mouseCallback(event, x, y, flags, param):
global mouseX, mouseY
if event == cv2.EVENT_LBUTTONDOWN:
mouseX = x
mouseY = y
主要函数
声明的变量mouseX和mouseY是用来保存点击点的像素坐标的。我们将使用它来演示对应的扫描线。初始化部分是不言自明的。我们正在实例化管道对象,设置左右摄像头,并调用getStereoPair()函数来设置stereo pair。
if __name__ == '__main__':
mouseX = 0
mouseY = 640
# Start defining a pipeline
pipeline = dai.Pipeline()
# Set up left and right cameras
monoLeft = getMonoCamera(pipeline, isLeft=True)
monoRight = getMonoCamera(pipeline, isLeft=False)
# Combine left and right cameras to form a stereo pair
stereo = getStereoPair(pipeline, monoLeft, monoRight)
将stereo输出连接到X-LinkOut节点
如上所述,我们将关注stereo节点的三个输出,disparity,rectifiedLeft和rectifiedRight。我们需要将这些输出连接到X-LinkOut节点,因为X-Link是设备用来通信的节点或机制。代码流程是:
- 创建各自的x-LinkOut节点
- 分配各自的流名称
- 将stereo输出连接到x-LinkOut节点作为输入
xoutDisp = pipeline.createXLinkOut()
xoutDisp.setStreamName("disparity")
xoutRectifiedLeft = pipeline.createXLinkOut()
xoutRectifiedLeft.setStreamName("rectifiedLeft")
xoutRectifiedRight = pipeline.createXLinkOut()
xoutRectifiedRight.setStreamName("rectifiedRight")
stereo.disparity.link(xoutDisp.input)
stereo.rectifiedLeft.link(xoutRectifiedLeft.input)
stereo.rectifiedRight.link(xoutRectifiedRight.input)
输送管道至OAK-D
一旦我们将所有节点正确链接,我们就可以将管道转移到设备(OAK-D)上。我们首先获取我们之前命名的流的输出序列。
- 每个序列被设置为一次最多保存1帧/消息。
- blocking = False意味着一旦序列满了就覆盖最后一帧。我们不想存储最后一帧,因为它不是必需的。
这disparityMultiplie定义为映射0–255范围内的视差值。这样做是为了对输出进行颜色映射,因为OpenCV函数需要该范围内的值。
with dai.Device(pipeline) as device:
# Output queues will be used to get the rgb frames and nn data
from the outputs defined above
disparityQueue = device.getOutputQueue(name="disparity",
maxSize=1, blocking=False)
rectifiedLeftQueue = device.getOutputQueue(name="rectifiedLeft",
maxSize=1, blocking=False)
rectifiedRightQueue=device.getOutputQueue(name="rectifiedRight",
maxSize=1, blocking=False)
# Calculate a multiplier for color mapping disparity map
disparityMultiplier = 255 / stereo.getMaxDisparity()
cv2.namedWindow("Stereo Pair")
cv2.setMouseCallback("Stereo Pair", mouseCallback)
# Variable use to toggle between side by side view and one frame
view.
sideBySide = False
主循环
我们使用预定义的函数getFrame从序列中获取视差帧。然后,该帧被乘以disparityMultiplier来映射0-255范围内的值。我们使用JET颜色图来可视化输出。这个颜色图的颜色范围从冷(蓝色)到热(红色)。
代码的其余部分几乎是一眼就能看懂的。我们从各自的序列中获取左和右的帧。根据切换状态,它经历了水平堆叠或合并。最后,我们有两个窗口作为输出:视差图和黑白摄像头流。
while True:
# Get the disparity map.
disparity = getFrame(disparityQueue)
# Colormap disparity for display.
disparity = (disparity *
disparityMultiplier).astype(np.uint8)
disparity = cv2.applyColorMap(disparity, cv2.COLORMAP_JET)
# Get the left and right rectified frame.
leftFrame = getFrame(rectifiedLeftQueue);
rightFrame = getFrame(rectifiedRightQueue)
if sideBySide:
# Show side by side view.
imOut = np.hstack((leftFrame, rightFrame))
else:
# Show overlapping frames.
imOut = np.uint8(leftFrame / 2 + rightFrame / 2)
# Convert to RGB.
imOut = cv2.cvtColor(imOut, cv2.COLOR_GRAY2RGB)
# Draw scan line.
imOut = cv2.line(imOut, (mouseX, mouseY),
(1280, mouseY), (0, 0, 255), 2)
# Draw clicked point.
imOut = cv2.circle(imOut, (mouseX, mouseY), 2,
(255, 255, 128), 2)
cv2.imshow("Stereo Pair", imOut)
cv2.imshow("Disparity", disparity)
# Check for keyboard input
key = cv2.waitKey(1)
if key == ord('q'):
# Quit when q is pressed
break
elif key == ord('t'):
# Toggle display when t is pressed
sideBySide = not sideBySide
效果
限制
OAK-D(或其他OAK-D系列变体相机)中的深度估计存在以下问题:
- 场景必须有纹理,而且不要是重复的纹理。
对于没有纹理的表面,找到相应的点很困难。当纹理具有重复的图案时,也会出现相同的情况。
- 在特定的距离范围内工作。
物体不能离相机太远。正如我们之前讨论的,视差与深度成反比。当物体远离相机移动并且图像看起来相同时,视差减小。OAK-D理论上能看到的最大深度是38.4米。在实践中,我们应该相信它高达约20米。
此外,当物体太近时,它也会失败。如果物体离相机很近,视差就会很大。相机帧的宽度是1280像素,但是在1280像素上搜索相应的点,计算成本很高。DepthAI API在对应于69厘米的96像素的小视差范围上搜索对应点。这大大加快了深度估计时间,但也意味着距离相机太近的物体深度估计将不太准确。
编者注:目前OAK-D-Pro因为带了结构光,可以改善上述情况1下的深度效果。 |
你可以在OAK-D中启用扩展视差(Extended Disparity)模式,将最小深度减少到35厘米。在这种模式下,API搜索191像素的视差。当你启用扩展视差时,帧速率将会下降。天下没有免费的午餐。
结论
这就是使用OAK-D或OAK-D Lite进行深度估计的全部内容。我希望你喜欢这篇文章,并且学到了新的东西。查看我们社区的令人兴奋的项目并且建立你自己的!
本系列的下一篇文章将讨论对象检测的管道。