车道线检测与地图匹配工程实践 - 第4节

第四章:经典车道线检测——霍夫变换直线检测与滑动窗口多项式拟合

各位同学,欢迎来到实战课的第四讲。

上一章我们聊了图像预处理,把原始图像变成了二值图。但二值图只是“点”的集合,我们真正需要的是“线”——也就是车道线。怎么从一堆白点里提取出直线?这就是今天要讲的核心:霍夫变换直线检测和滑动窗口多项式拟合。

我个人习惯把这两个方法称为“经典双雄”。一个负责找直线,一个负责拟合曲线。咱们一个一个来。


4.1 霍夫变换:从“点”到“线”的魔法

4.1.1 为什么需要霍夫变换?

你想想看,二值图里每条车道线其实是由成千上万个像素点组成的。如果直接对这些点做线性回归,结果会很糟糕——因为车道线不一定是完美的直线,而且噪声点会严重干扰拟合。

霍夫变换的思路很巧妙:它把图像空间里的点,映射到参数空间里去投票。说白了,就是让每个像素点都去“支持”它可能属于的那条直线,最后得票最多的参数组合就是我们要找的直线。

核心思想: 图像空间中的一个点,对应参数空间中的一条正弦曲线。多个点对应的曲线如果交于一点,说明这些点共线。

4.1.2 数学原理(尽量简单说)

传统的直线方程是 y = kx + b。但这里有个坑:当直线垂直时,k 是无穷大,没法处理。所以霍夫变换改用极坐标表示:

ρ = x·cosθ + y·sinθ

其中 ρ 是原点到直线的距离,θ 是直线的法线与 x 轴的夹角。这样一来,参数空间就是 (ρ, θ) 平面,范围有限,好处理多了。

我记得第一次看这个公式时也觉得有点绕。但你可以这么理解:对于图像中的每个白点 (x, y),我们让 θ 从 0 到 180 度变化,算出对应的 ρ,然后在 (ρ, θ) 空间里累加投票。最后找到投票数超过阈值的点,就是检测到的直线。

4.1.3 OpenCV 实现

OpenCV 提供了两个函数:HoughLines 和 HoughLinesP。前者返回 (ρ, θ) 参数对,后者是改进版——概率霍夫变换,直接返回线段的端点坐标,更实用。

import cv2
import numpy as np

# 假设 edge_img 是经过 Canny 边缘检测的二值图
lines = cv2.HoughLinesP(
    edge_img,          # 输入二值图
    rho=1,             # ρ 的步长(像素)
    theta=np.pi/180,   # θ 的步长(弧度)
    threshold=50,      # 投票阈值,越大检测到的直线越少
    minLineLength=100, # 最小线段长度
    maxLineGap=50      # 同一条线上点之间的最大间隔
)

# lines 的格式:[[[x1, y1, x2, y2]], ...]
for line in lines:
    x1, y1, x2, y2 = line[0]
    cv2.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2)

避坑指南: 我曾经在高速场景下把 threshold 设得太低(比如 30),结果路边的护栏、路沿全被检测成车道线了。后来我总结了一个经验:threshold 值大约取图像高度的一半左右比较稳妥。比如图像高 720 像素,threshold 设 350-400 就挺好。

4.1.4 霍夫变换的局限性

霍夫变换虽然经典,但有两个明显短板:

  • 只能检测直线——弯道就抓瞎了
  • 参数敏感——threshold、minLineLength 这些参数需要针对不同场景调

所以实际工程中,我们通常用霍夫变换做初步筛选,然后结合其他方法处理弯道。这就引出了我们的第二个主角——滑动窗口多项式拟合。


4.2 滑动窗口多项式拟合:搞定弯道

4.2.1 基本思路

说白了,滑动窗口法就是把图像从下往上分成一个个小窗口,在每个窗口里找车道线的像素点,然后把这些点的中心连起来,最后用多项式去拟合。

为什么叫“滑动窗口”?因为窗口是从底部往上“滑”的,每个窗口的位置由上一个窗口的中心决定。这样即使车道线是弯曲的,窗口也能跟着拐弯。

4.2.2 实现步骤

嗯,这里我直接上代码,边看边解释:

def sliding_window_polyfit(binary_warped):
    """
    滑动窗口多项式拟合
    binary_warped: 经过透视变换的二值图
    """
    # 1. 统计图像下半部分的像素分布,找到左右车道线的起始位置
    histogram = np.sum(binary_warped[binary_warped.shape[0]//2:, :], axis=0)
    midpoint = histogram.shape[0] // 2
    leftx_base = np.argmax(histogram[:midpoint])   # 左车道起始x
    rightx_base = np.argmax(histogram[midpoint:]) + midpoint  # 右车道起始x

    # 2. 设置滑动窗口参数
    nwindows = 9          # 窗口数量
    window_height = binary_warped.shape[0] // nwindows  # 每个窗口高度
    margin = 100          # 窗口宽度的一半
    minpix = 50           # 窗口内最少像素点数

    # 3. 找到所有非零像素点的坐标
    nonzero = binary_warped.nonzero()
    nonzeroy = np.array(nonzero[0])
    nonzerox = np.array(nonzero[1])

    # 4. 开始滑动
    leftx_current = leftx_base
    rightx_current = rightx_base
    left_lane_inds = []
    right_lane_inds = []

    for window in range(nwindows):
        # 计算窗口的上下边界
        win_y_low = binary_warped.shape[0] - (window+1) * window_height
        win_y_high = binary_warped.shape[0] - window * window_height

        # 左右窗口的左右边界
        win_xleft_low = leftx_current - margin
        win_xleft_high = leftx_current + margin
        win_xright_low = rightx_current - margin
        win_xright_high = rightx_current + margin

        # 找出窗口内的像素点
        good_left_inds = ((nonzeroy >= win_y_low) & (nonzeroy < win_y_high) &
                          (nonzerox >= win_xleft_low) & (nonzerox < win_xleft_high)).nonzero()[0]
        good_right_inds = ((nonzeroy >= win_y_low) & (nonzeroy < win_y_high) &
                           (nonzerox >= win_xright_low) & (nonzerox < win_xright_high)).nonzero()[0]

        left_lane_inds.append(good_left_inds)
        right_lane_inds.append(good_right_inds)

        # 如果窗口内像素足够多,更新窗口中心位置
        if len(good_left_inds) > minpix:
            leftx_current = int(np.mean(nonzerox[good_left_inds]))
        if len(good_right_inds) > minpix:
            rightx_current = int(np.mean(nonzerox[good_right_inds]))

    # 5. 合并所有窗口的索引
    left_lane_inds = np.concatenate(left_lane_inds)
    right_lane_inds = np.concatenate(right_lane_inds)

    # 6. 提取像素坐标
    leftx = nonzerox[left_lane_inds]
    lefty = nonzeroy[left_lane_inds]
    rightx = nonzerox[right_lane_inds]
    righty = nonzeroy[right_lane_inds]

    # 7. 二阶多项式拟合
    left_fit = np.polyfit(lefty, leftx, 2)
    right_fit = np.polyfit(righty, rightx, 2)

    return left_fit, right_fit, left_lane_inds, right_lane_inds

注意: 这里的多项式拟合用的是 np.polyfit(y, x, 2),注意参数顺序!我们拟合的是 x = f(y),而不是 y = f(x)。因为车道线在图像中是竖直走向的,用 y 作为自变量更合理。我见过不少新手在这里搞反,结果拟合出来的曲线歪七扭八。

4.2.3 为什么用二阶多项式?

你可能会问:为什么不用一阶(直线)或三阶(更复杂)?

一阶多项式就是直线,处理不了弯道。三阶多项式虽然拟合能力更强,但容易过拟合——稍微有点噪声,曲线就扭得不成样子。二阶多项式是个很好的折中:能描述弯道,又不会太敏感。

我在实际项目中测试过,对于高速公路的弯道(曲率半径通常大于 200 米),二阶多项式拟合的误差在 5 厘米以内,完全够用。


4.3 本章知识体系

下面这张图总结了本章的核心逻辑:

第四章:经典车道线检测知识体系 二值化图像 直线/弯道? 霍夫变换直线检测 HoughLinesP 滑动窗口多项式拟合 二阶多项式 x = f(y) 车道线参数(系数)

4.4 两种方法的对比

对比项 霍夫变换直线检测 滑动窗口多项式拟合
适用场景 直线车道(高速、城市直道) 弯道、直道均可
输出结果 线段端点 (x1,y1,x2,y2) 多项式系数 (a, b, c)
计算速度 快(毫秒级) 中等(10-20ms)
抗噪能力 一般(参数敏感) 较好(窗口机制过滤噪声)
实现复杂度 低(OpenCV 一行调用) 中等(需要自己写滑动逻辑)

我的建议: 实际工程中,我通常两种方法都跑。先用霍夫变换快速判断车道是不是直的,如果是直线就直接输出;如果是弯道,再调用滑动窗口做多项式拟合。这样既保证了速度,又兼顾了弯道场景。


4.5 常见问题与避坑

4.5.1 霍夫变换检测到很多杂线怎么办?

我曾经在一条有新旧车道线的路上调试,结果霍夫变换检测出了十几条线,新旧标线混在一起。后来我加了两个后处理:

  • 角度过滤: 车道线的角度通常在 30°-60° 之间(相对于水平方向),超出这个范围的直接丢弃
  • 聚类合并: 把角度和位置相近的线段合并成一条

4.5.2 滑动窗口跟丢了怎么办?

如果车道线突然消失(比如被前车遮挡),滑动窗口可能会跑偏。我的做法是:如果连续 3 个窗口都没找到足够的像素点,就保持当前中心位置不变,继续往上滑。等车道线重新出现时,窗口能很快跟上。

4.5.3 多项式拟合结果抖动怎么办?

单帧拟合的结果往往会有抖动。解决方案是加时间维度的平滑——把前几帧的拟合系数做个加权平均。我一般用指数移动平均,权重系数设 0.7 左右,效果不错。


好了,这一章的内容就到这儿。霍夫变换和滑动窗口多项式拟合是车道线检测的两大基石,理解了它们,后面的高级方法(比如基于深度学习的检测)学起来会轻松很多。

记住:经典方法虽然“老”,但它们的思路至今仍在各种算法中沿用。把基础打牢,后面才能走得更远。

可可精品资料库