在一张路面照片里找到两条白色的车道线,并算出【车道中心】与【画面中心】差了多少——这六个字的差值,就是方向盘的输入。 这条流水线(灰度 → 模糊 → 边缘 → 只看下方梯形区 → 找直线段 → 合成两条车道线)是四十年不过时的经典视觉方案:没有任何模型,全是几何,却快得能在树莓派上跑到 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) 只做一件事:如果某个像素处的明暗变化够剧烈,就把它标成边缘。但"够剧烈"要有个门槛,门槛就是这两个数:
- 高阈值 high:超过它 → 一定是边缘(强边缘);
- 低阈值 low:低于它 → 一定不是边缘;
- 夹在中间的:只有当它与某个"确定的边缘"相连时才算边缘(这叫滞后阈值,是为了不让边缘断掉)。
起手参数 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 为例):
- 全画面像素 =
320 × 240 = 76800 - 三角形面积 =
1/2 × 底 320 × 高 144 = 23040(高 = 240 − 96 = 144) - 占比 =
23040 ÷ 76800 = 30%
只留下三成像素,而被砍掉的七成恰好是最容易产生假边缘的地方(天空的云、树叶、建筑外墙、远处车辆)。Canny 和 Hough 的输入直接少 70%,误检的绝对值下降得比这还多——这是整条流水线里最划算的一步。
三角形顶点定在 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
读结果:
roi_edges上方全黑 = ROI 生效了。如果上方还有白线,说明fillPoly的坐标写错了。- 红线抖动、一跳一跳 → 这是经典方案的通病,必须做帧间平滑(见下)。
err稳定在 ±10 以内 = 车基本在车道中央。err = 车道中心 − 画面中心:正数表示车道中心在画面右侧,即车偏在车道左边,要向右修正。转向写法与 16-2 完全一致(turn > 0表示向右修正):
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)
- 验算一次:
lane_center = 168、mid_x = 160、W/2 = 160→error = 8/160 = 0.05;取kp = 0.5→turn = 0.025→ 左轮比右轮快一点点 → 轻微向右修正。方向对、力度轻,正是直线行驶该有的样子。 - 帧间抖动要治:保存上一帧结果做移动平均(例如
lane_center = 0.7 * 新值 + 0.3 * 旧值),比任何参数微调都管用。
七、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 的技术在这里复用)。
一句话总结边界:这套是【几何法】,它假设【车道线是亮背景上的直线】;假设一旦不成立,它就会安静地变差,而不是报错。 真正的量产自驾车要在后面接上感知融合与深度学习——这也是为什么这个领域到今天还在卷。
九、常见误区
- 【Canny 阈值随便写两个数就完事】——高低比要落在 1:2 ~ 1:3 才有意义;高低比一乱,滞后阈值那套机制就失效。且必须按现场光照调,白天室外和室内是完全不同的两套值。
- 【不做 ROI,直接对整幅图跑霍夫】——你家的窗帘、家具、墙角全是直线。ROI 不是优化项,是可用性前提(第七节实验亲测)。
- 【车道线应该被检测成一条完整直线】——
HoughLinesP返回的是线段集合,一条车道线常常被拆成好几段。必须自己做聚类+平均,否则"车道中心"根本算不出来。 - 【算斜率时不用管 x2 == x1】——垂直线(x2 == x1)的斜率是无穷大,直接除会得到
inf或抛错。先continue跳过。 - 【一帧算出来的转向就该立刻执行】——帧间抖动会让车在路上画龙。必须做帧间平滑(移动平均或丢弃突变),这是能不能稳定跑起来的关键,比调
kp重要得多。 - 【把这套用到弯道/夜间也没问题】——不能。它只在白天直道可靠,弯道要透视变换+多项式拟合,夜间要加颜色掩膜。它失效时不会报错,只会安静地变差,所以要靠可视化窗口盯。
- 【
threshold是"要检出几条线"】——不是,它是每条候选线至少要有多少个边缘点投票。含义搞错的话,参数就越调越乱。
自测三问(点开对答案)
1. **为什么车道检测的第一、二步是【灰度】和【高斯模糊】?** 灰度的原因:边缘检测只关心明暗突变,**颜色信息在这里没用**,三通道变单通道能把数据量砍到三分之一、速度提高约三倍。模糊的原因:**Canny 会把每一个噪点都当成候选边缘**,先高斯模糊抹掉高频噪声,边缘才是"真边缘"。 2. **ROI 掩膜为什么是这条流水线里性价比最高的一步?** 因为它**只保留下方三角形(约占画面 30% 的像素)**,而被砍掉的 70% 恰好是最多假边缘的地方(天空、树、建筑、远处车辆)。**Canny 与霍夫的输入直接少 70%**,误检下降得比这更多。代价是**上方 40% 完全看不见**,所以弯道会滞后。 3. **`HoughLinesP` 检出了一堆线段,怎么变成【车道中心】这个数?** 先**按斜率分左右**(图像 y 向下,左道斜率为负、右道为正,|斜率| < 0.5 的近水平线丢弃),**各组分别平均**得到左右两条代表线;再把代表线套回画面底部取 x 坐标;**两道的 x 取平均即车道中心**,与画面中心相减得 `error`,最后经比例控制变成左右轮速度差。- ☐ 下一节(16-4 打造树莓派 AI 自驾车)
相关笔记
- OpenCV 色彩检测与追踪 —— 同一套【掩膜+几何】思路:那边掩膜来自颜色,这边来自边缘
- 打造自动避障和物体追踪车 —— 比例控制与差速转向的出处,本节直接复用
- OpenCV 的基本使用 —— 灰度转换:数据量砍三分之二的账在这里兑现
- 打造树莓派 AI 自驾车 —— 本节输出的
turn在收官篇接到电机上 - 🍓 树莓派 · 索引 —— 全课路线图