🍓 第十六章 硬件接口实验范例(二):树莓派 AI 自驾车
📄 一句话概括

在一张路面照片里找到两条白色的车道线,并算出【车道中心】与【画面中心】差了多少——这六个字的差值,就是方向盘的输入。 这条流水线(灰度 → 模糊 → 边缘 → 只看下方梯形区 → 找直线段 → 合成两条车道线)是四十年不过时的经典视觉方案:没有任何模型,全是几何,却快得能在树莓派上跑到 30fps。

📝 本节和前面几节的关系

与 16-1 是同一种思路的两种用法:16-1 靠【颜色】判断哪个像素属于目标,本节靠【边缘】判断哪个像素属于车道线——都是先做掩膜、再做几何计算,只是掩膜的来源从 inRange 换成了 Canny。 11-3 的灰度转换在这里是第一步且是必需的:边缘检测只关心明暗突变,三个通道一起算纯属浪费(数据量砍到三分之一,速度快三倍)。 16-2 的比例控制与差速转向在这里被原封不动复用,只是输入从【色块的 cx】换成了【车道中心与画面中心的差】。 与 16-4 的分工:本节把【怎么看得准】讲透,输出是画面上的一条绿线(可视化验证);把这条绿线接到电机上真正让车跑起来,是 16-4 的收官任务。

一、先把任务想清楚:我们要的到底是什么

要的不是【一条线】,而是一个作用在电机上的数。所以整条流水线的终点应该是:

车道中心 x  -  画面中心 x  =  error  ->  turn  ->  左右轮速度

中间的六个步骤全都服务于一件事:让这个 error 越准越好、越稳越好。想清这一点,就能理解为什么每一步长成那样——它不是在"识别车道",它是在"给转向找一个可信的输入"。

步骤 做什么 为什么需要它
① 转灰度 3 通道 → 1 通道 边缘检测不看颜色;数据量砍到三分之一
② 高斯模糊 抹掉高频噪点 否则 Canny 会把每一粒噪点都当边缘
③ Canny 边缘 找出明暗突变点 车道线与路面之间正是强边缘
④ ROI 梯形掩膜 只保留下方一块 天空、树、建筑全在画面上方,一刀砍掉能消掉八九成误检
⑤ HoughLinesP 把边缘点连成线段 Canny 只给散点,霍夫把它们投成直线
⑥ 分左右 + 平均 聚合成两条车道线 左右各会检出多条线段,要合成两条才有意义

二、第三步:Canny 的两个阈值怎么理解

cv2.Canny(img, low, high) 只做一件事:如果某个像素处的明暗变化够剧烈,就把它标成边缘。但"够剧烈"要有个门槛,门槛就是这两个数:

起手参数 50, 150 的来历:经验上高低比取 1:2 ~ 1:3 比较稳。想让 Canny 自适应,也可以从图像的中值算:

median = np.median(blurred)
low  = int(max(0, 0.7 * median))
high = int(min(255, 1.3 * median))

调参口诀:边缘太少(车道线断断续续)→ 两个阈值都调低;边缘太多(满地噪声)→ 两个阈值都调高或加大高斯核。别只动一个——高低比一乱,滞后阈值那套就失效了。

三、第四步:ROI 是本条流水线里性价比最高的一刀

我们把 ROI 做成一个三角形:底边是画面最下面一整条,顶点在画面高度的 40% 处。

pts = [(0, H), (W, H), (W // 2, int(H * 0.4))]   # 三角形的三个顶点(左下、右下、顶点)
polygon = np.array([pts], np.int32)              # 注意是【列表里再装这个顶点列表】
mask = np.zeros_like(edges)          # 全黑的同尺寸图
cv2.fillPoly(mask, polygon, 255)     # 在 mask 上把三角形涂白
roi = cv2.bitwise_and(edges, mask)   # 按位与:只留三角形里的边缘

算一下这一刀砍掉了多少(以 320×240 为例):

只留下三成像素,而被砍掉的七成恰好是最容易产生假边缘的地方(天空的云、树叶、建筑外墙、远处车辆)。Canny 和 Hough 的输入直接少 70%,误检的绝对值下降得比这还多——这是整条流水线里最划算的一步。

⚠️ ROI 的代价是【只能看近处】

三角形顶点定在 40% 高度处,意味着画面上面 40% 的内容一律不看。好处是干净,代价是远处(弯道开始的地方)也看不见了——这正是本方法在弯道上失效的直接原因(见第七节)。想让车早点看到弯,就把顶点抬到 0.5 甚至 0.6,但误检也会跟着回来。这是一个必须手工权衡的旋钮。

四、第五步:HoughLinesP 的五个参数

cv2.HoughLinesP 用的是概率霍夫变换:它不返回无穷长的直线,而是返回线段端点 (x1, y1, x2, y2)——这正好是我们画线要的格式。

lines = cv2.HoughLinesP(edges,
                        rho=1,             # 距离分辨率:1 像素
                        theta=np.pi / 180, # 角度分辨率:1 度
                        threshold=50,      # 一条线至少要有 50 个点投票才算数
                        minLineLength=40,  # 短于 40 像素的线段丢掉
                        maxLineGap=20)     # 同一条线上两段之间的空隙 < 20 就接起来
参数 调大的效果 调小的效果
threshold 只留最有把握的线(漏检变多) 杂线变多
minLineLength 只要长直线(虚线被丢掉) 短碎线变多
maxLineGap 虚线更容易被接成一条 虚线断成好几段

threshold 最容易被误解:它不是【检测到几条线】,而是【一条候选线至少要得到多少个边缘点投票】。现场的调法就是先打印 len(lines):输出几十上百条 → 把 threshold 和 minLineLength 往上提;输出 0 条 → 往下调(或者回头看 Canny 是不是太严了)。

五、第六步:把一堆线段合成【左右两条】

霍夫给出的线段有长有短、还可能一条车道线上检出四五段。聚类办法是按斜率分左右:

for ln in lines:
    x1, y1, x2, y2 = ln[0]
    if x2 == x1:                      # 垂直线:斜率无穷大,跳过,否则除零
        continue
    slope = (y2 - y1) / (x2 - x1)
    if abs(slope) < 0.5:              # 太接近水平:路面纹理或阴影,丢掉
        continue
    intercept = y1 - slope * x1       # 直线写成 y = slope * x + intercept
    if slope < 0:
        left_fit.append((slope, intercept))
    else:
        right_fit.append((slope, intercept))

为什么左道斜率是负的(这一条最容易晕):图像的 y 轴向下为正。左车道线在画面里是【底部靠左、越往上越靠中间】——也就是说从下往上走,x 在增大、y 在减小。代入 slope = (y2 - y1) / (x2 - x1):分子为负、分母为正,斜率为负。右车道线恰好相反(从下往上走 x 在减小、y 也在减小,负除负),斜率为正。记住结论:左负右正。

两条聚类各取平均,就得到两条"代表线":

left_avg  = np.average(left_fit,  axis=0) if left_fit  else None
right_avg = np.average(right_fit, axis=0) if right_fit else None

再把 (slope, intercept) 反向算回画面上的两个点,就能画线了:

def line_to_points(params):
    if params is None:
        return None
    slope, intercept = params
    y1, y2 = H, int(H * 0.6)                    # 从底部画到 60% 高度
    x1 = int((y1 - intercept) / slope)
    x2 = int((y2 - intercept) / slope)
    return x1, y1, x2, y2

六、动手 Demo:整条流水线 + 可视化

先只做可视化、先不接电机——把每一级的中间结果都调出来看,是调参的唯一办法。

import cv2
import numpy as np

W, H = 320, 240


def region_of_interest(edges):
    pts = [(0, H), (W, H), (W // 2, int(H * 0.4))]
    polygon = np.array([pts], np.int32)
    mask = np.zeros_like(edges)
    cv2.fillPoly(mask, polygon, 255)
    return cv2.bitwise_and(edges, mask)


def split_lines(lines):
    left_fit, right_fit = [], []
    if lines is None:
        return left_fit, right_fit
    for ln in lines:
        x1, y1, x2, y2 = ln[0]
        if x2 == x1:                    # 垂直线,跳过
            continue
        slope = (y2 - y1) / (x2 - x1)
        if abs(slope) < 0.5:            # 近水平,丢掉
            continue
        intercept = y1 - slope * x1
        if slope < 0:
            left_fit.append((slope, intercept))
        else:
            right_fit.append((slope, intercept))
    return left_fit, right_fit


def line_to_points(params):
    if params is None:
        return None
    slope, intercept = params
    y1, y2 = H, int(H * 0.6)
    x1 = int((y1 - intercept) / slope)
    x2 = int((y2 - intercept) / slope)
    return x1, y1, x2, y2


cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, W)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, H)

while True:
    ok, frame = cap.read()
    if not ok:
        break

    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)          # ① 灰度
    blur = cv2.GaussianBlur(gray, (5, 5), 0)                # ② 模糊
    edges = cv2.Canny(blur, 50, 150)                        # ③ 边缘
    roi = region_of_interest(edges)                         # ④ 梯形掩膜

    lines = cv2.HoughLinesP(roi, 1, np.pi / 180,
                            threshold=50, minLineLength=40, maxLineGap=20)

    left_fit, right_fit = split_lines(lines)                # ⑤ 分左右
    left_avg = np.average(left_fit, axis=0) if left_fit else None
    right_avg = np.average(right_fit, axis=0) if right_fit else None

    overlay = np.zeros_like(frame)
    left_pts = line_to_points(left_avg)
    right_pts = line_to_points(right_avg)
    if left_pts:
        cv2.line(overlay, (left_pts[0], left_pts[1]), (left_pts[2], left_pts[3]),
                 (0, 0, 255), 4)
    if right_pts:
        cv2.line(overlay, (right_pts[0], right_pts[1]), (right_pts[2], right_pts[3]),
                 (0, 0, 255), 4)

    # 车道中心 vs 画面中心
    mid_x = W // 2
    if left_pts and right_pts:
        lane_center = (left_pts[0] + right_pts[0]) // 2
    elif left_pts:
        lane_center = left_pts[0] + 160                     # 只看到左道,往右估
    elif right_pts:
        lane_center = right_pts[0] - 160                    # 只看到右道,往左估
    else:
        lane_center = mid_x                                 # 都没看到:直行

    cv2.line(overlay, (lane_center, H), (lane_center, int(H * 0.6)), (0, 255, 0), 2)
    cv2.line(overlay, (mid_x, H), (mid_x, int(H * 0.6)), (255, 0, 0), 2)

    out = cv2.addWeighted(frame, 0.8, overlay, 1, 0)
    cv2.putText(out, "lane {} mid {} err {}".format(lane_center, mid_x,
                lane_center - mid_x), (8, 20),
                cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1)

    cv2.imshow("lane", out)
    cv2.imshow("roi_edges", roi)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

cap.release()
cv2.destroyAllWindows()

看到什么算成功:

窗口 lane     :路面两侧各有一条红线(合成后的车道线),中间一条绿线(车道中心),
               蓝线是画面中心;两条线几乎重合时说明车在车道正中
窗口 roi_edges:只有画面下方三角形区域里有白色边缘细线,上方全黑
终端/画面上的小字:lane 168 mid 160 err 8

读结果:

error = (lane_center - mid_x) / (W / 2)      # 归一化到 -1 ~ +1
turn  = kp * error                           # kp 从 0.5 左右试起
left_speed  = clamp(base + turn)             # 左快 = 右转(16-2 的表)
right_speed = clamp(base - turn)

七、30 秒实验:看一眼 ROI 到底挡住了什么

把 region_of_interest() 这一行临时注释掉,直接把完整的 edges 喂给 HoughLinesP,同时打印线段数:

# roi = region_of_interest(edges)
roi = edges
...
print("lines =", 0 if lines is None else len(lines))

预期变化:roi_edges 里瞬间冒出密密麻麻的白线(窗帘、家具、墙角、天花板灯全在发光),len(lines) 从个位数涨到几十甚至上百条,画面上的红线开始乱指。

结论一句话:ROI 不是【优化】,是【这一步能不能用】的前提——没有它,霍夫会在整幅图里到处找直线,而"直线"在你家客厅里简直遍地都是。

八、必须坦白:这套方法的能力边界

教材里这条流水线效果很好,但它有明确的适用范围,讲不清这一点就是在误导人:

场景 表现 为什么
白天、直道、车道线清晰 很好用(本节的适用场景) Canny 能找到强边缘、直道正好是直线
弯道 明显滞后甚至丢线 三角形 ROI 只覆盖近处;直线拟合描述不了曲线
夜间 / 强逆光 大幅退化 对比度不足,Canny 找不到边缘
雨天 / 积雪 / 车道线磨损 失效 没有边缘可找,或水膜反光造成假边缘
阴影横穿路面 误检 阴影边界也是强边缘

弯道怎么办(方向,不是本节任务):把【霍夫直线】换成 透视变换 + 多项式拟合——先把画面俯视化,再拟合一条二次曲线。夜间则要加颜色掩膜(把白色/黄色像素先挑出来再找边缘,16-1 的技术在这里复用)。

一句话总结边界:这套是【几何法】,它假设【车道线是亮背景上的直线】;假设一旦不成立,它就会安静地变差,而不是报错。 真正的量产自驾车要在后面接上感知融合与深度学习——这也是为什么这个领域到今天还在卷。

九、常见误区

  1. 【Canny 阈值随便写两个数就完事】——高低比要落在 1:2 ~ 1:3 才有意义;高低比一乱,滞后阈值那套机制就失效。且必须按现场光照调,白天室外和室内是完全不同的两套值。
  2. 【不做 ROI,直接对整幅图跑霍夫】——你家的窗帘、家具、墙角全是直线。ROI 不是优化项,是可用性前提(第七节实验亲测)。
  3. 【车道线应该被检测成一条完整直线】——HoughLinesP 返回的是线段集合,一条车道线常常被拆成好几段。必须自己做聚类+平均,否则"车道中心"根本算不出来。
  4. 【算斜率时不用管 x2 == x1】——垂直线(x2 == x1)的斜率是无穷大,直接除会得到 inf 或抛错。先 continue 跳过。
  5. 【一帧算出来的转向就该立刻执行】——帧间抖动会让车在路上画龙。必须做帧间平滑(移动平均或丢弃突变),这是能不能稳定跑起来的关键,比调 kp 重要得多。
  6. 【把这套用到弯道/夜间也没问题】——不能。它只在白天直道可靠,弯道要透视变换+多项式拟合,夜间要加颜色掩膜。它失效时不会报错,只会安静地变差,所以要靠可视化窗口盯。
  7. 【threshold 是"要检出几条线"】——不是,它是每条候选线至少要有多少个边缘点投票。含义搞错的话,参数就越调越乱。
自测三问(点开对答案) 1. **为什么车道检测的第一、二步是【灰度】和【高斯模糊】?** 灰度的原因:边缘检测只关心明暗突变,**颜色信息在这里没用**,三通道变单通道能把数据量砍到三分之一、速度提高约三倍。模糊的原因:**Canny 会把每一个噪点都当成候选边缘**,先高斯模糊抹掉高频噪声,边缘才是"真边缘"。 2. **ROI 掩膜为什么是这条流水线里性价比最高的一步?** 因为它**只保留下方三角形(约占画面 30% 的像素)**,而被砍掉的 70% 恰好是最多假边缘的地方(天空、树、建筑、远处车辆)。**Canny 与霍夫的输入直接少 70%**,误检下降得比这更多。代价是**上方 40% 完全看不见**,所以弯道会滞后。 3. **`HoughLinesP` 检出了一堆线段,怎么变成【车道中心】这个数?** 先**按斜率分左右**(图像 y 向下,左道斜率为负、右道为正,|斜率| < 0.5 的近水平线丢弃),**各组分别平均**得到左右两条代表线;再把代表线套回画面底部取 x 坐标;**两道的 x 取平均即车道中心**,与画面中心相减得 `error`,最后经比例控制变成左右轮速度差。

相关笔记