简介:面向计算机视觉初学者与自动驾驶相关开发者的 OpenCV 车道实时检测示例代码,完整演示了从视频逐帧读取、按文件名排序、灰度化,到创建多边形掩码并提取感兴趣区域,再到二值阈值化和霍夫线变换检测直线的典型处理流程。文档重点解释了 cv2.bitwise_and 掩码运算、cv2.threshold 阈值设置、cv2.HoughLinesP 的 rho、theta、threshold、maxLineGap 等参数含义,并说明如何通过 cv2.VideoWriter 将标注后的帧重新合成视频,最终获得带实时车道标记的输出结果。代码中利用 tqdm 显示进度、使用 try 语句容错,也体现了实际工程中的处理细节。资源为单个 PDF 文档,共 194KB,内容直观,适合边读边动手实现。目前已有 307 人学习下载,是快速入门 OpenCV 车道线识别的实用材料。文中还提到可引入 Canny 边缘检测或卷积神经网络进一步提升检测精度,并在原代码基础上预留了改进空间,适合在此基础上继续做算法优化或移植到自己的项目中。
1. 车道实时检测没有想象中神秘:一份能直接跑通的OpenCV示例代码
问OpenCV车道实时检测怎么做的人,一半是准备毕设,一半是刚进自动驾驶或ADAS相关岗位。听到“车道检测”第一反应是上深度学习,YOLO、语义分割一套组合拳,其实在固定机位的行车记录仪场景里,OpenCV传统的图像处理链路——灰度化、掩码提取、阈值二值化、霍夫线变换——就够用了。这份示例代码正是这么一套完全不依赖GPU的方案:输入道路视频的拆帧图片,输出画好车道线的标注视频,每一步都有可视化中间结果,适合在地面跑通第一个视觉闭环。适合刚接触视觉处理的学生,也适合想快速验证霍夫变换和ROI掩码工程效果的工程师。动手前先确认环境,命令行执行pip list | grep opencv能看到opencv-python版本再往下走,省得import cv2第一步就翻车。
2. 预处理链路先把路“框”出来:帧读取、灰度化与ROI掩码
2.1 视频帧读取与文件名排序:先拿到能处理的图片序列
这段代码把frames/文件夹下的所有图片读进来,按帧名里的数字排序,顺序不能乱。
import os import re import cv2 import numpy as np from tqdm import notebook import matplotlib.pyplot as plt col_frames = os.listdir('frames/') col_frames.sort(key=lambda f: int(re.sub('\D', '', f))) col_images = [] for i in notebook.tqdm(col_frames): img = cv2.imread('frames/' + i) col_images.append(img)os.listdir返回的文件名顺序是不固定的,直接 sort 会按字典序排,frame_100.jpg会排到frame_2.jpg前面。这里的sort用了一个关键处理:re.sub('\D', '', f)把文件名里所有非数字字符删掉,['frame_002.jpg', 'frame_100.jpg']变成['002', '100'],再int()转成数字排序,保证视频帧按真实拍摄顺序排列。tqdm.notebook用来显示读取进度,图片在 Jupyter 里处理时能直观看到还剩多少张。cv2.imread默认按彩色三通道读入,所以col_images里每张图都是一个(height, width, 3)的 uint8 数组,后续取灰度通道用[:,:,0]就行。
2.2 创建掩码:为什么ROI用梯形而不是矩形
车道在图像里是近宽远窄的梯形,直接画矩形会把路肩、对向车道和天空都包进来,后面阈值化和霍夫变换会检出一堆干扰线。所以掩码区域用多边形框定。
idx = 457 stencil = np.zeros_like(col_images[idx][:,:,0]) polygon = np.array([[50,270], [220,160], [360,160], [480,270]]) cv2.fillConvexPoly(stencil, polygon, 1)col_images[idx][:,:,0]取出第 457 帧的灰度通道,np.zeros_like生成一张和它尺寸完全相同的全 0 矩阵,作为掩码底图。polygon里四个顶点是图像坐标系下的坐标,(50,270)是左下角(梯形左底),(480,270)是右下角(梯形右底),中间两个点(220,160)和(360,160)是梯形上边,对应远处车道变窄的位置。cv2.fillConvexPoly用 1 填充这个梯形内部,于是stencil里车道区域全是 1,其余区域是 0。
注意:这四个坐标是针对示例视频的分辨率手工标定出来的,换视频必须重新标。我一般把第一帧截图到画图工具里,把车道两侧边界线的交点记下来再填进去,直接照抄别人的坐标大概率跑偏。
2.3 bitwise_and把ROI抠出来:一次看懂掩码运算
掩码建好后,用按位与把车道区域从整帧图里抠出来,这是整个预处理里最核心的一步。
masked = cv2.bitwise_and(col_images[idx][:,:,0], col_images[idx][:,:,0], mask=stencil)cv2.bitwise_and对两个输入逐像素做按位与。同一张图传两次是 OpenCV 里的习惯写法,相当于只保留自己;真正的关键在mask参数——它指定哪些位置的像素参与运算。stencil里为 1 的像素保留原灰度值,为 0 的像素输出直接清零。于是掩码外的路肩、天空、对面车道全部变黑,只剩梯形车道区域,后续检测的干扰范围大幅缩小。
参数上有一点容易踩坑:mask必须和输入图同尺寸、单通道、值只能是 0 或 1。如果传进去的是一张普通灰度图而不是严格二值掩码,按位与会按二进制位逐位计算,产生意料之外的暗化效果,画面会变成一片灰蒙蒙。
2.4 阈值化:把车道线从灰度里“拉”出来
掩码之后是阈值化,目的是让车道线像素和路面背景彻底分开。
ret, thresh = cv2.threshold(masked, 130, 145, cv2.THRESH_BINARY)THRESH_BINARY的规则是:像素值大于 130 的置为 145,小于等于 130 的置为 0。车道线比路面亮,经过这一步变成亮线,暗背景被清掉,输出是单通道二值图。这里要特别说清一点:第三个参数是maxval,不是区间上界,别理解成“只保留 130 到 145 之间的像素”——它是“比阈值大的所有像素都变成 145”。ret返回实际使用的阈值,这里就是 130,调试时可以打印出来确认。
为什么用 145 而不是常用的 255?对霍夫变换来说,非零像素的具体数值不影响直线检测结果,只要和背景有区分度就行,255 和 145 效果一样。真正需要重新标定的是 130 这个阈值:强光照下路面灰度经常超过 130,就调到 150~160;阴天或隧道里调低到 100~110。我一般会把掩码后的灰度图用plt.imshow看一眼,再决定阈值往哪个方向调。
3. 核心检测:HoughLinesP六个参数逐个说清,再动手画线
3.1 HoughLinesP的六个参数:每个参数都在控制什么
霍夫线变换是整个检测链路的引擎,代码里所有可调参数都在这一行:
lines = cv2.HoughLinesP(thresh, 1.0, np.pi/180, 30, maxLineGap=200)HoughLinesP是概率霍夫变换,它不遍历所有边缘点,而是随机采样一部分点投票,效率比标准霍夫高,适合实时场景。六个参数含义如下:
| 参数 | 示例代码取值 | 含义 | 调大后的影响 |
|---|---|---|---|
rho | 1.0 | 线段距离精度,单位像素 | 精度变粗,短线段可能检不到 |
theta | np.pi/180 | 角度精度,单位弧度,约 1 度 | 角度分辨率变粗,斜线拟合变差 |
threshold | 30 | 累加器投票阈值,线段至少得到 30 票 | 只保留更长更明显的线段,线段数变少 |
minLineLength | 未传,默认 None | 线段最小长度,None 表示不限制 | 过滤短碎线,但可能丢掉弯道处的短段 |
maxLineGap | 200 | 同方向线段合并的最大间隙,单位像素 | 把断开的同一条线连起来,过大则误连相邻车道 |
注意minLineLength在示例代码里没有传,用的默认值 None,也就是不限制线段最短长度。这会让一些碎石边缘、路面裂缝被当成直线检出来,后面画线时会发现多了不少短横线。如果视频里这种噪声明显,建议显式传minLineLength=30,先过滤一波短碎线。
3.2 从参数到策略:怎么调出稳定车道线而不是满屏短线
调霍夫参数确实有点玄学,但本质是投票数、线段长度、间隙容忍度三者之间的平衡。常见现象是threshold从 30 调到 10,路面裂缝、阴影边缘都满足投票条件,画面上全是碎线;maxLineGap从 200 调到 50,车道中间因为磨损断线被识别成多段,画出来是虚线效果。
我一般按这个顺序调:先固定rho=1.0、theta=np.pi/180不动,只调threshold,观察检测出的线段是否稳定;再调maxLineGap,让同一侧车道线连成完整长线;最后才考虑minLineLength过滤噪声。一次只动一个参数,并且用连续几十帧验证,不要盯着一帧调到完美,换一帧就废。车道线检测最忌讳单帧调参,那叫过拟合画面,不叫参数可用。
3.3 把检测结果画出来:坐标提取与画布选择
检测到线段后,要把它们画到图像上做可视化。这里有个容易忽略的细节:画到灰度图上和画到彩色图上,颜色表现完全不同。
dmy = col_images[idx][:,:,0].copy() for line in lines: x1, y1, x2, y2 = line[0] cv2.line(dmy, (x1, y1), (x2, y2), (255, 0, 0), 3)HoughLinesP的返回值是三维矩阵,shape 是(m, 1, 4),m是线段条数,最内层四个值就是线段端点坐标。line[0]取出这条线段的x1, y1, x2, y2,再交给cv2.line画线。dmy是灰度图的副本,cv2.line在单通道图上画线时颜色只取 BGR 三元组第一个分量 255,显示为白色,线宽 3 像素。如果画到彩色帧上,(255, 0, 0)就是蓝色粗线——最终合成视频时用的就是彩色版本。
提示:为什么先
copy()再画?因为HoughLinesP返回的是像素坐标,直接画在原图上会污染原始帧,后面想重新调参又得重新读图。调试期养成在副本上画线的习惯,能省很多重读图的时间。
4. 合成为视频:VideoWriter的编码、尺寸与逐帧写盘
4.1 VideoWriter:编码格式、尺寸和帧率怎么配对
所有帧处理完后要合成视频文件,VideoWriter是 OpenCV 的输出出口。参数不多,但尺寸和编码的坑不少。
pathOut = 'roads_v2.mp4' fps = 30.0 height, width = img.shape[:2] size = (width, height) out = cv2.VideoWriter(pathOut, cv2.VideoWriter_fourcc(*'DIVX'), fps, size)VideoWriter四个参数依次是:输出路径、编码格式、帧率、帧尺寸。size必须写成(width, height),而 OpenCV 图像shape返回的是(height, width),正好反着。img.shape[:2]只取前两个值,避开彩色图第三通道维度的干扰。
编码格式由cv2.VideoWriter_fourcc决定,常见组合:
| fourcc | 容器扩展名 | 特点 |
|---|---|---|
| DIVX | .avi | 老牌组合,兼容性最好,示例代码默认 |
| mp4v | .mp4 | 通用 MP4,大多数播放器能放 |
| XVID | .avi | 压缩率适中,调试常用 |
| MJPG | .avi | 几乎不压缩,文件大,但任何环境都能写 |
示例代码里DIVX写进.mp4,在部分平台能跑,在另一些平台上文件打不开或只有 0 字节。我的习惯是调试阶段用MJPG + .avi,确认检测效果没问题再换成mp4v + .mp4出正式结果,省得一路调参一路处理输出文件打不开的玄学问题。
4.2 逐帧处理循环:从单帧验证到批量生产
单帧流程验证通过后,把同样的操作套到所有帧上,循环体内就是完整的检测链路。
for img in notebook.tqdm(col_images): masked = cv2.bitwise_and(img[:,:,0], img[:,:,0], mask=stencil) ret, thresh = cv2.threshold(masked, 130, 145, cv2.THRESH_BINARY) lines = cv2.HoughLinesP(thresh, 1, np.pi/180, 30, maxLineGap=200) dmy = img.copy() try: for line in lines: x1, y1, x2, y2 = line[0] cv2.line(dmy, (x1, y1), (x2, y2), (255, 0, 0), 3) out.write(dmy) except TypeError: out.write(img) out.release()循环里每帧重复掩码、阈值化、霍夫变换、画线、写盘的流程。这里dmy = img.copy()用的是彩色原图副本,所以最终视频里车道线是蓝色的,和第 3 章灰度图上画线表现不一样。try/except TypeError是关键容错:当画面里没有满足条件的直线时,HoughLinesP返回None,for line in lines直接抛 TypeError。捕获后写入原始帧,这一帧就没有车道线标注,但整个视频输出不会中断。
mask全程用同一个stencil,隐含假设是摄像头在整个视频过程中位置固定、视角不变。如果设备有震动或位移,这个假设失效,掩码区域会偏离实际车道,检测结果会飘。try/except不是吞异常偷懒,而是“这一帧检测不到就原样写出”的容错策略——丢掉一帧的标注比中断整个视频合理得多。
4.3 完整代码:修正后的可运行版本
把上面所有串起来,就是一份可以直接运行的完整脚本:
import os import re import cv2 import numpy as np from tqdm import notebook import matplotlib.pyplot as plt col_frames = os.listdir('frames/') col_frames.sort(key=lambda f: int(re.sub('\D', '', f))) col_images = [] for i in notebook.tqdm(col_frames): img = cv2.imread('frames/' + i) col_images.append(img) # 创建固定ROI掩码 stencil = np.zeros_like(col_images[0][:,:,0]) polygon = np.array([[50,270], [220,160], [360,160], [480,270]]) cv2.fillConvexPoly(stencil, polygon, 1) # 输出视频参数 pathOut = 'roads_v2.mp4' fps = 30.0 height, width = img.shape[:2] # 用shape[:2]而不是直接解包,避免ValueError size = (width, height) out = cv2.VideoWriter(pathOut, cv2.VideoWriter_fourcc(*'DIVX'), fps, size) # 逐帧处理 for img in notebook.tqdm(col_images): masked = cv2.bitwise_and(img[:,:,0], img[:,:,0], mask=stencil) ret, thresh = cv2.threshold(masked, 130, 145, cv2.THRESH_BINARY) lines = cv2.HoughLinesP(thresh, 1, np.pi/180, 30, maxLineGap=200) dmy = img.copy() try: for line in lines: x1, y1, x2, y2 = line[0] cv2.line(dmy, (x1, y1), (x2, y2), (255, 0, 0), 3) out.write(dmy) except TypeError: out.write(img) out.release() print('done, check roads_v2.mp4')相比网上流传的版本,这里做了一个必要修正:height, width = img.shape[:2]。原因是加载帧循环结束后,img是最后一次cv2.imread的结果,彩色图 shape 是三元组(height, width, 3),直接解包给两个变量会抛ValueError: too many values to unpack——这个问题在避坑章节还会细说。运行前确认frames/文件夹有拆好的帧图片,帧数最好 100 张以上,fps 按实际拍摄帧率设置,不要一律填 30。输出roads_v2.mp4后,用播放器多拖几段进度条看效果,别只看开头几秒就下结论。
5. 避坑手册:帧排序、空检测、数组解包与视频编码四个坑
5.1 帧文件名排序翻车:字母排序会让画面倒着跑
现象:处理完的视频里车道线位置跳变,车辆前进方向混乱,严重时看起来像倒放。
原因:os.listdir返回的文件名顺序本身不确定,直接调用sort()按字符串排,frame_100.jpg会排在frame_2.jpg前面——因为字符'1'比'2'小。帧顺序一乱,合成的视频画面就乱跳。
解决:sort的key必须用int(re.sub('\D', '', f)),把所有非数字字符删掉后再转整型排序。如果文件名里没有数字,正则提取不到内容,int()会抛异常,所以拆帧时命名务必带连续数字编号,比如frame_001.jpg、frame_002.jpg。
顺带说一句环境问题:如果import cv2直接报ModuleNotFoundError: No module named 'cv2',多半是环境里压根没装 OpenCV,pip install opencv-python装完就行。如果是“装了 OpenCV 但找不到 cv2”,通常是 pip 装到了 user 目录而解释器路径不对,用python -m pip install --force-reinstall opencv-python重装最省事。
5.2 HoughLinesP返回None直接崩:TypeError的真相
现象:循环跑到某一帧突然抛TypeError: argument of type 'NoneType' is not iterable,程序中断,前面的白跑了。
原因:画面里完全没有满足条件的直线时,HoughLinesP返回None,而for line in lines会尝试迭代None。
解决:代码里已经用try/except TypeError把写帧操作包住,检测不到就写原始帧。更显式的写法是if lines is not None:再迭代,逻辑一目了然。try/except的优点是代码短,缺点是如果画线时其他位置也抛了 TypeError,也会被误吞,导致输出帧没有标注还看不出原因。调试期建议先把lines打印出来确认是不是None。
5.3 完整代码直接跑报ValueError: too many values to unpack
现象:把网上流传的完整版代码存下来直接运行,在height, width = img.shape这一行崩溃,错误是ValueError: too many values to unpack。
原因:img此时是最后一次cv2.imread读进来的彩色帧,shape 是(height, width, 3)三个值,用两个变量接不住。网上不少转载版本没改这个位置,能跑通是因为原作者在 Jupyter 里按分段执行,前面把img重新赋值为灰度图了,shape 只有两个值才解包成功。直接跑完整脚本的人几乎都会踩这一脚。
解决:一律写height, width = img.shape[:2],或者在定义size时直接用(col_images[0].shape[1], col_images[0].shape[0]),不依赖循环结束后遗留的img变量。
5.4 视频文件0字节或打不开:编码和尺寸在捣乱
现象:程序正常跑完,输出文件只有几 KB,或者播放器直接报错无法打开。
原因:DIVX编码写进.mp4容器在部分平台上兼容性差;size写反,把 height 放到 width 位置,和实际帧尺寸不一致时,VideoWriter可能根本不写数据。
解决:先确认size = (width, height),width取shape[1]、height取shape[0]。还不行就换编码:fourcc = cv2.VideoWriter_fourcc(*'MJPG')、输出路径改成.avi,MJPG 在几乎所有平台上都能写动,缺点是文件大。最直接的检查是处理完打印out.isOpened(),返回True才说明初始化成功,否则参数必然有问题。
5.5 单帧效果很好但连续视频抖动:固定掩码遇上光照变化
现象:截某一帧看,车道线画得又直又准;合成出来的视频里线却时断时续、忽左忽右。
原因:polygon是固定的,假设相机位置和角度全程不变;阈值 130 也是全局固定,早晚光照、树影会让部分帧的车道线灰度跌破阈值,检测直接失效。
解决:先确认摄像头确实固定,如果有震动就要做帧间配准或动态 ROI。阈值层面,把全局阈值换成 Canny 边缘检测或自适应阈值,具体做法在下一章。验证时别只看一帧,连续看几十帧,检测成功率到八成以上才算参数基本可用。单帧完美不能说明任何问题,连续稳定才能交给下一个环节。
6. 让检测更稳:Canny边缘检测替代阈值化,再用抽帧批处理验证参数
6.1 升级一:Canny + HoughLinesP,减少光照干扰
固定阈值对光照变化极其敏感,一个简单的升级是把cv2.threshold换成cv2.Canny,霍夫部分完全不用改:
edge = cv2.Canny(masked, 50, 150) lines = cv2.HoughLinesP(edge, 1, np.pi/180, 30, maxLineGap=200)Canny 先算梯度再双阈值滞后,50 和 150 分别是低阈值和高阈值。边缘强度在两者之间、且连接到高阈值边缘的像素会被保留。和全局阈值相比,Canny 是局部梯度响应,不是绝对灰度,所以对光照渐变更鲁棒。车道线在画面里是比较强的边缘,低阈值设 40~60、高阈值取低阈值的 2~3 倍是一个合理的起手区间。低阈值设太低会把路面裂缝全检出来,设太高虚线车道线会断成碎段,这个参数需要按自己的视频再磨一下。
6.2 验证方法:抽帧跑批处理,别对着单帧调参
调参最怕只盯一帧,换一帧就废。我的习惯是从整段视频里随机抽 10 帧,跑一遍完整链路,统计每帧检出的线段数:
import random random.seed(42) sample_idx = random.sample(range(len(col_images)), 10) for idx in sample_idx: frame = col_images[idx][:,:,0] masked = cv2.bitwise_and(frame, frame, mask=stencil) edge = cv2.Canny(masked, 50, 150) lines = cv2.HoughLinesP(edge, 1, np.pi/180, 30, maxLineGap=200) count = 0 if lines is None else len(lines) print(f"frame {idx}: {count} lines")随机抽帧能覆盖白天、阴影、弯道等不同情况。如果某几帧是 0,说明参数在这些场景失效。我一般要求 10 帧里至少 8 帧能检出超过 2 条线段,再合视频看效果。批处理的好处是把“看起来行”变成“统计上行”,比单帧调参靠谱得多。
那段固定polygon的掩码在摄像头震动或车身颠簸时会偏出真实车道线区域,典型表现是弯道时画出的线飞出去。最简单的补救是把多边形整体往外扩一圈,给误差留余量;更完整的做法是每隔 N 帧重新检测画面里的强边界,自动更新梯形顶点。这份示例代码是固定 ROI 版本,够覆盖固定机位的大部分场景,接真实行车记录仪时建议先标定相机,再决定要不要上动态 ROI。
我最早跑车道检测时,被那个polygon坐标坑过——以为自己拿到的视频和示例是同一类机位,直接照抄四个顶点,结果检出的“车道线”全在路肩上。后来我养成一个习惯:每换一段视频,第一件事是截第一帧,在画图工具里把车道边界描一遍,得出新的polygon,再调阈值,最后用随机抽帧批处理验证。这个顺序看着啰嗦,但能省掉大量来回试参的时间。到现在我处理固定机位的视觉任务,还是先框 ROI、再谈检测这套起手式。希望帮到你。
本文还有配套的精品资源,点击获取