如何在没有深度学习的情况下寻找车道

164 阅读7分钟

在没有深度学习的情况下寻找车道

今天,我们将在一个项目中一起工作,使用python在图像和视频中寻找车道。在这个项目中,我们将采取手动方式。即使我们使用深度学习等技术确实可以获得更好的结果,但我们学习概念、工作原理、基础知识也很重要,这样当我们建立高级模型时,就可以应用我们已经学到的知识。在使用深度学习时,我们所介绍的一些步骤可能也是需要的。

我们要采取的步骤如下。

  • 计算相机校准并解决失真问题。
  • 应用透视变换来整顿二元图像("鸟瞰")。
  • 使用颜色变换、梯度等,创建一个阈值的二元图像。
  • 检测车道像素并进行拟合以找到车道边界。
  • 确定车道的曲率和车辆相对于中心的位置。
  • 将检测到的车道边界扭曲到原始图像上。
  • 输出车道边界的视觉显示以及车道曲率和车辆位置的数字估计。

计算相机校准

今天的廉价针孔摄像机给图像带来了很多失真。两个主要的失真是径向失真和切向失真。

由于径向失真,直线会显得很弯曲。当我们远离图像的中心时,它的影响更大。例如,下面显示的一幅图像,一个棋盘的两个边缘用红线标出。但你可以看到,边界不是一条直线,与红线不匹配。所有预期的直线都被凸出来了。

image.png 相机失真实例

为了解决这个问题,我们将使用OpenCVpython库,并使用目标相机拍摄的样本图像到一个棋盘。为什么是棋盘?在棋盘图像中,我们可以很容易地测量失真,因为我们知道物体的外观,我们可以计算出源点到目标点的距离,并使用它们来计算失真系数,然后我们可以用来修复图像。

下一张图片显示了一个来自来的输出图像的例子,以及未扭曲的结果图像。

image.png

修复相机失真

(所有这些神奇的事情都发生在文件lib/camera.py ),但它是如何工作的呢?这个过程包括3个步骤。

对图像进行采样。

在这一步中,我们确定定义棋盘网格的角,如果我们找不到棋盘,或者棋盘不完整,我们就丢弃样本图像。

# first we convert the  image to grayscale
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Find the chessboard corners
ret, corners = cv2.findChessboardCorners(gray, (9, 6), None)

校准。

在这一步中,我们从校准图案的几个视图中找到相机的内在和外在参数,然后我们可以用它来生成结果图像。

img_size = (self._valid_images[0].shape[1], self._valid_images[0].shape[0])
ret, self._mtx, self._dist, t, t2 = cv2.calibrateCamera(self._obj_points, self._img_points, img_size, None, None)

消除扭曲。

在这最后一步中,我们根据在校准步骤中检测到的参数,通过补偿镜头失真来实际产生结果图像。

cv2.undistort(img, self._mtx, self._dist, None, self._mtx)

应用透视变换来矫正二元图像("鸟瞰")。

这个过程的下一步是改变图像的视角,从安装在汽车前部的常规摄像机视角变为俯视图,也称为 "鸟瞰图"。下面是它的样子。

image.png

未扭曲的图像

(所有这些魔法都发生在文件中lib/image_processor.py)

这种转换非常简单,我们在屏幕上取四个我们知道的点,然后将这些点转换为屏幕上的理想位置。让我们以上面的图片为例,更详细地回顾一下它。在图片中,我们看到一个绿色的形状被画在上面,这个矩形是用四个源点作为角,它与摄像机的常规直线道路重叠。这个矩形围绕着图像的中心,由于透视的原因,街景通常会在这里结束,以便给天空留出位置。现在我们把这些点移到屏幕上我们想要的位置,也就是把绿色区域变成一个矩形,从0到图片的高度,这里是我们代码中要使用的源点和目标点。

height, width, color = img.shape

src = np.float32([
    [210, height],
    [1110, height],
    [580, 460],
    [700, 460]
])

dst = np.float32([
    [210, height],
    [1110, height],
    [210, 0],
    [1110, 0]
])

一旦确定了这些点,就像使用OpenCV再次施展它的魔法一样简单。

src, dst = self._calc_warp_points(img)

if self._M is None:
    self._M = cv2.getPerspectiveTransform(src, dst)
    self._M_inv = cv2.getPerspectiveTransform(dst, src)

return cv2.warpPerspective(img, self._M, (width, height), flags=cv2.INTER_LINEAR)

使用颜色变换、梯度等,创建一个阈值的二进制图像。

现在我们已经有了图像,我们需要开始舍弃其中所有不相关的信息,只保留线条。为此,我们将应用一系列的变化,接下来我们将详细介绍。

转换为灰度

将彩色图像转换为灰度

return cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)

增强图像

用高斯模糊法对图像进行平滑处理,并将原始图像和平滑后的图像加权,从而进行一些微小但重要的增强。

dst = cv2.GaussianBlur(img, (0, 0), 3)
out = cv2.addWeighted(img, 1.5, dst, -0.5, 0)

用Sobel对水平梯度进行阈值处理。

计算X轴上颜色变化函数的导数,并应用一个阈值来过滤高强度的颜色变化,由于我们使用的是灰度,所以会有边界。

sobel = cv2.Sobel(img, cv2.CV_64F, True, False)
abs_sobel = np.absolute(sobel)
scaled_sobel = np.uint8(255 * abs_sobel / np.max(abs_sobel))

return (scaled_sobel >= 20) & (scaled_sobel <= 220)

梯度方向阈值,以便只检测接近垂直的边缘,使用索贝尔。

现在我们计算新阈值上的方向性导数

# Calculate the x and y gradients
sobel_x = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=sobel_kernel)
sobel_y = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=sobel_kernel)

# Take the absolute value of the x and y gradients
gradient_direction = np.arctan2(np.absolute(sobel_y), np.absolute(sobel_x))
gradient_direction = np.absolute(gradient_direction)

return (gradient_direction >= np.pi/6) & (gradient_direction <= np.pi*5/6)

接下来,我们把它们合并成一个梯度

# combine the gradient and direction thresholds.
gradient_condition = ((sx_condition == 1) & (dir_condition == 1))

颜色阈值

这个滤波器适用于原始图像,我们试图只获得那些偏黄/偏白的像素(就像公路线一样)。

r_channel = img[:, :, 0]
g_channel = img[:, :, 1]
return (r_channel > thresh) & (g_channel > thresh)

L层和S层的HSL阈值

对于这项任务,有必要改变颜色空间,特别是,我们将使用HSL颜色空间,因为它对我们使用的图像具有有趣的特性。

def _hls_condition(self, img, channel, thresh=(220, 255)):
    channels = {
        "h": 0,
        "l": 1,
        "s": 2
    }
    hls = cv2.cvtColor(img, cv2.COLOR_RGB2HLS)

    hls = hls[:, :, channels[channel]]

    return (hls > thresh[0]) & (hls <= thresh[1])

最后我们把所有的东西组合成一个最终的图像。

grey = self._to_greyscale(img)
grey = self._enhance(grey)

# apply gradient threshold on the horizontal gradient
sx_condition = self._sobel_gradient_condition(grey, 'x', 20, 220)

# apply gradient direction threshold so that only edges closer to vertical are detected.
dir_condition = self._directional_condition(grey, thresh=(np.pi/6, np.pi*5/6))

# combine the gradient and direction thresholds.
gradient_condition = ((sx_condition == 1) & (dir_condition == 1))

# and color threshold
color_condition = self._color_condition(img, thresh=200)

# now let's take the HSL threshold
l_hls_condition = self._hls_condition(img, channel='l', thresh=(120, 255))
s_hls_condition = self._hls_condition(img, channel='s', thresh=(100, 255))

combined_condition = (l_hls_condition | color_condition) & (s_hls_condition | gradient_condition)
result = np.zeros_like(color_condition)
result[combined_condition] = 1

我们的新图像现在看起来如下。

image.png

|

真棒!你已经能看到那里形成的线条了吗?

检测车道像素,并进行拟合以找到车道的边界。

到目前为止,我们能够创建一个由眼鸟视图组成的图像,其中只包含车道特征(至少在大部分情况下,我们仍有一些噪音)。有了这个新的图像,现在我们可以开始做一些计算,将图像转化为我们可以使用的实际数值,如车道位置和曲率。

让我们先来确定图像上的像素,并建立一个代表车道函数的多项式。我们打算怎么做呢?原来有一个非常聪明的方法,使用图像下半部分的直方图,这里是一个直方图的例子,它看起来像什么。

image.png

直方图

图像上的峰值帮助我们识别车道的左边和右边。这里是建立直方图在代码上的样子。

# Take a histogram of the bottom half of the image
histogram = np.sum(binary_warped[binary_warped.shape[0] // 2:, :], axis=0)

# Find the peak of the left and right halves of the histogram
# These will be the starting point for the left and right lines
midpoint = np.int(histogram.shape[0] // 2)
left_x_base = np.argmax(histogram[:midpoint])
right_x_base = np.argmax(histogram[midpoint:]) + midpoint 

但是你可能会问,为什么只有下半部分呢?答案是,我们只想关注紧挨着汽车的那部分,因为车道可能会有一个曲线,这会影响我们的直方图。一旦我们找到了靠近汽车的车道的位置,我们就可以使用移动窗口的方法来寻找其余的部分,正如我们在下一张图片中所详述的。

image.png

移动窗口处理实例

下面是代码上的样子。

# Choose the number of sliding windows
num_windows = 9
# Set the width of the windows +/- margin
margin = 50
# Set minimum number of pixels found to recenter window
min_pix = 100

# Set height of windows - based on num_windows above and image shape
window_height = np.int(binary_warped.shape[0] // num_windows)

# Current positions to be updated later for each window in nwindows
left_x_current = left_x_base
right_x_current = right_x_base

# Create empty lists to receive left and right lane pixel indices
left_lane_inds = []
right_lane_inds = []

# Step through the windows one by one
for window in range(num_windows):
    # Identify window boundaries in x and y (and right and left)
    win_y_low = binary_warped.shape[0] - (window + 1) * window_height
    win_y_high = binary_warped.shape[0] - window * window_height
    win_x_left_low = left_x_current - margin
    win_x_left_high = left_x_current + margin
    win_x_right_low = right_x_current - margin
    win_x_right_high = right_x_current + margin

    if self._debug:
        # Draw the windows on the visualization image
        cv2.rectangle(out_img, (win_x_left_low, win_y_low),
                      (win_x_left_high, win_y_high), (0, 255, 0), 2)
        cv2.rectangle(out_img, (win_x_right_low, win_y_low),
                      (win_x_right_high, win_y_high), (0, 255, 0), 2)

    # Identify the nonzero pixels in x and y within the window #
    good_left_inds = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) &
                      (nonzero_x >= win_x_left_low) & (nonzero_x < win_x_left_high)).nonzero()[0]
    good_right_inds = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) &
                       (nonzero_x >= win_x_right_low) & (nonzero_x < win_x_right_high)).nonzero()[0]

    # Append these indices to the lists
    left_lane_inds.append(good_left_inds)
    right_lane_inds.append(good_right_inds)

    # If you found > min_pix pixels, recenter next window on their mean position
    if len(good_left_inds) > min_pix:
        left_x_current = np.int(np.mean(nonzero_x[good_left_inds]))
    if len(good_right_inds) > min_pix:
        right_x_current = np.int(np.mean(nonzero_x[good_right_inds]))

# Concatenate the arrays of indices (previously was a list of lists of pixels)
try:
    left_lane_inds = np.concatenate(left_lane_inds)
    right_lane_inds = np.concatenate(right_lane_inds)
except ValueError:
    # Avoids an error if the above is not implemented fully
    pass

这个过程非常密集,所以在处理视频时,我们可以调整一些东西,因为我们并不总是需要从零开始,之前的计算给了我们一个车道接下来可能出现的窗口,所以更容易找到。所有这些都在资源库上的最终代码中实现了,请随意看看。

一旦我们有了所有的窗口,我们现在就可以直接用所有确定的点建立多项式,每条线(左边和右边)将独立计算,如下所示。

left_fit = np.polyfit(left_y, left_x, 2)
right_fit = np.polyfit(right_y, right_x, 2)

数字2代表一个二阶多项式。

确定车道的曲率和车辆相对于中心的位置。

现在我们知道了这些线在图像上的位置,也知道了汽车的位置(在摄像机的中心),我们可以做一些有趣的计算来确定车道的曲率和汽车相对于车道中心的位置。

车道的曲率

车道的曲率是一个对多项式的简单计算。

fit_cr = np.polyfit(self.all_y * self._ym_per_pix, self.all_x * self._xm_per_pix, 2)
plot_y = np.linspace(0, 720 - 1, 720)
y_eval = np.max(plot_y)

curve = ((1 + (2 * fit_cr[0] * y_eval * self._ym_per_pix + fit_cr[1]) ** 2) ** 1.5) / np.absolute(2 * fit_cr[0])

但有一个重要的考虑,这一步我们不能用像素来计算,我们需要找到一种方法将像素转换成米,所以我们引入了两个变量。_ym_per_pix和_xm_per_pix是预先定义好的值,我们不做过多介绍,你可以把这个值拿出来,如果你想找到更多,有一些程序可以使用算法和摄像机信息来识别这个值。

self._xm_per_pix = 3.7 / 1280
self._ym_per_pix = 30 / 720

车辆相对于中心的位置

很简单,计算车道中间的位置,并将其与图像的中心进行比较,像这样

lane_center = (self.left_lane.best_fit[-1] + self.right_lane.best_fit[-1]) / 2
car_center = img.shape[1] / 2
dx = (car_center - lane_center) * self._xm_per_pix

全部完成!

现在你有了所有你需要的信息,以及表示车道的多项式。你的最终结果应该如下所示。 image.png

image.png

image.png

image.png

和样本视频

也许不完全是......还记得我们把图像包装成眼鸟视图吗?好吧,你需要还原效果,把多项式渲染成原始图像,但我把这个留给你做作业,或者直接在我的代码中查看。