第三章 车道线拟合与跟踪:滑动窗口法、二次曲线拟合、卡尔曼滤波跟踪、车道线置信度评估

车道线检测做到这一步,其实才刚走完一半。

你想想看,图像里提取出一堆像素点,它们只是散落的「点云」。怎么把这些点变成一条有意义的线?怎么让这条线在下一帧还能稳定跟上?这就是本章要啃的硬骨头。

我个人习惯把车道线处理拆成四个环节:搜索窗口 → 曲线拟合 → 状态跟踪 → 置信度打分。缺一个,系统就容易崩。

3.1 滑动窗口法:从像素到候选点

为什么不用全局搜索?因为太慢了。

实际道路图像里,车道线只占画面底部的一小条区域。滑动窗口法的思路很简单:从下往上,一个窗口一个窗口地找。

核心步骤:

  1. 取图像下半部分的直方图,找到左右两个峰值作为起始点
  2. 从底部向上滑动窗口(宽约80-120像素,高约40-60像素)
  3. 每个窗口内统计非零像素点,如果数量超过阈值(比如30个),就更新窗口中心
  4. 记录所有窗口内的像素坐标,作为后续拟合的输入

我在项目中遇到过一个问题:弯道曲率太大时,窗口会跟丢。后来我加了一个「记忆偏移」机制——上一帧的拟合曲线位置,作为当前帧窗口的初始偏移量。效果立竿见影。

def sliding_window(binary_warped, nwindows=9, margin=100, minpix=50):
    """
    滑动窗口法提取车道线像素
    binary_warped: 透视变换后的二值图
    """
    # 直方图统计底部一半
    histogram = np.sum(binary_warped[binary_warped.shape[0]//2:, :], axis=0)
    
    # 找到左右峰值
    midpoint = histogram.shape[0] // 2
    leftx_base = np.argmax(histogram[:midpoint])
    rightx_base = np.argmax(histogram[midpoint:]) + midpoint
    
    # 窗口高度
    window_height = binary_warped.shape[0] // nwindows
    
    # 存储所有非零像素坐标
    nonzero = binary_warped.nonzero()
    nonzeroy = np.array(nonzero[0])
    nonzerox = np.array(nonzero[1])
    
    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 = np.int32(np.mean(nonzerox[good_left_inds]))
        if len(good_right_inds) > minpix:
            rightx_current = np.int32(np.mean(nonzerox[good_right_inds]))
    
    # 合并所有索引
    left_lane_inds = np.concatenate(left_lane_inds)
    right_lane_inds = np.concatenate(right_lane_inds)
    
    leftx = nonzerox[left_lane_inds]
    lefty = nonzeroy[left_lane_inds]
    rightx = nonzerox[right_lane_inds]
    righty = nonzeroy[right_lane_inds]
    
    return leftx, lefty, rightx, righty

避坑指南:窗口的margin值不要设死。我曾经在高速场景用固定100像素,结果匝道弯道直接飞线。后来改成动态margin——根据上一帧曲率自适应调整,弯道时放大到150,直道缩回80。

3.2 二次曲线拟合:把点变成线

拿到像素点之后,下一步就是拟合。为什么用二次曲线?因为车道线在透视变换后的鸟瞰图里,基本符合抛物线形状。三次曲线过拟合,一次曲线又太刚。

说白了,二次曲线是「刚刚好」的选择。

def fit_poly(leftx, lefty, rightx, righty):
    """
    二次曲线拟合
    返回: 左右车道线的多项式系数
    """
    # 拟合二次曲线: f(y) = A*y^2 + B*y + C
    # 注意这里是以y为自变量,因为车道线是纵向的
    left_fit = np.polyfit(lefty, leftx, 2)
    right_fit = np.polyfit(righty, rightx, 2)
    
    return left_fit, right_fit

# 生成拟合曲线点
def generate_points(ploty, left_fit, right_fit):
    left_fitx = left_fit[0]*ploty**2 + left_fit[1]*ploty + left_fit[2]
    right_fitx = right_fit[0]*ploty**2 + right_fit[1]*ploty + right_fit[2]
    return left_fitx, right_fitx

为什么用y做自变量?

常规思维是用x拟合y,但车道线是垂直走向的。一个x可能对应多个y值(比如弯道)。反过来,每个y值只对应一个车道线x位置。所以用y做自变量更合理。

嗯,这里要注意:拟合之前一定要做异常值剔除。我见过有人直接把所有点扔进去拟合,结果一个噪点把整条曲线带偏了。建议先用RANSAC或者简单的标准差过滤。

3.3 卡尔曼滤波跟踪:让曲线「稳」下来

单帧拟合的结果,抖动很大。为什么?因为图像噪声、光照变化、车辆颠簸,都会让像素点位置波动。

卡尔曼滤波就是干这个的——它把上一帧的状态和当前帧的观测融合起来,输出一个「平滑」的估计。

状态量 含义 维度
A, B, C 二次曲线系数 3
ΔA, ΔB, ΔC 系数变化率(速度) 3

我建议用6维状态向量:3个曲线系数 + 3个变化率。这样模型能预测下一帧的曲线位置,即使这一帧检测失败,也能用预测值顶上去。

class LaneKalmanFilter:
    def __init__(self):
        # 状态向量: [A, B, C, dA, dB, dC]
        self.x = np.zeros(6)
        # 状态协方差矩阵
        self.P = np.eye(6) * 1000
        # 状态转移矩阵 (匀速模型)
        self.F = np.array([
            [1, 0, 0, 1, 0, 0],
            [0, 1, 0, 0, 1, 0],
            [0, 0, 1, 0, 0, 1],
            [0, 0, 0, 1, 0, 0],
            [0, 0, 0, 0, 1, 0],
            [0, 0, 0, 0, 0, 1]
        ])
        # 观测矩阵 (只观测曲线系数)
        self.H = np.array([
            [1, 0, 0, 0, 0, 0],
            [0, 1, 0, 0, 0, 0],
            [0, 0, 1, 0, 0, 0]
        ])
        # 过程噪声
        self.Q = np.eye(6) * 0.01
        # 观测噪声
        self.R = np.eye(3) * 0.1
    
    def predict(self):
        self.x = self.F @ self.x
        self.P = self.F @ self.P @ self.F.T + self.Q
        return self.x[:3]  # 返回预测的曲线系数
    
    def update(self, z):
        # z: 当前帧拟合得到的曲线系数 [A, B, C]
        y = z - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        self.P = (np.eye(6) - K @ self.H) @ self.P
        return self.x[:3]

参数调优经验:过程噪声Q和观测噪声R的比值,决定了滤波器的「信任倾向」。Q/R越大,滤波器越相信观测值,响应快但抖动大;反之则越平滑但滞后。我一般先设Q=0.01, R=0.1,然后根据实车数据微调。

3.4 车道线置信度评估:别让烂数据害了你

卡尔曼滤波再牛,也架不住输入数据是垃圾。所以必须给每条拟合出来的车道线打个分。

我常用的评估维度有四个:

  • 拟合残差:像素点到拟合曲线的平均距离。超过5个像素就扣分。
  • 宽度一致性:左右车道线的间距是否在合理范围(3.5米左右)。偏差超过20%直接降权。
  • 曲率连续性:当前帧曲率与上一帧曲率的差值。突变超过30%说明可能跟丢了。
  • 像素密度:有效像素点数量。少于200个点,置信度打五折。
def compute_confidence(left_fit, right_fit, leftx, lefty, rightx, righty, prev_curvature):
    score = 0.0
    
    # 1. 拟合残差
    left_residual = np.mean(np.abs(left_fit[0]*lefty**2 + left_fit[1]*lefty + left_fit[2] - leftx))
    right_residual = np.mean(np.abs(right_fit[0]*righty**2 + right_fit[1]*righty + right_fit[2] - rightx))
    if left_residual < 5 and right_residual < 5:
        score += 0.3
    
    # 2. 宽度一致性 (假设图像底部y=720)
    y_eval = 720
    left_x = left_fit[0]*y_eval**2 + left_fit[1]*y_eval + left_fit[2]
    right_x = right_fit[0]*y_eval**2 + right_fit[1]*y_eval + right_fit[2]
    lane_width = right_x - left_x
    # 假设标准宽度为 3.7米,像素宽度约 600-800
    if 600 < lane_width < 800:
        score += 0.3
    
    # 3. 曲率连续性
    current_curvature = compute_curvature(left_fit, y_eval)
    if prev_curvature is not None:
        curvature_diff = abs(current_curvature - prev_curvature) / max(prev_curvature, 1)
        if curvature_diff < 0.3:
            score += 0.2
    
    # 4. 像素密度
    if len(leftx) > 200 and len(rightx) > 200:
        score += 0.2
    
    return min(score, 1.0)

实战技巧:置信度低于0.5时,我选择完全信任卡尔曼滤波的预测值,不更新观测。等连续3帧置信度都恢复后,再重新融合观测。这个策略帮我扛过了很多隧道出口的强光干扰。

最后说一句:车道线跟踪不是一锤子买卖。它是一个闭环——当前帧的置信度会影响卡尔曼增益,卡尔曼输出又会影响下一帧的窗口位置。把这个循环跑顺了,你的车道保持才算真正「稳」了。