☰
OpenCV车道线检测全流程:Canny、ROI、霍夫变换与拟合调参避坑
2026/10/30 2:49:20 网站建设 项目流程

简介:面向计算机视觉初学者与自动驾驶入门开发者的 OpenCV 车道线检测实践代码包,围绕图像预处理、边缘检测、轮廓提取、霍夫变换与线性拟合等环节,搭建从单帧道路图像到车道线拟合结果的完整流程,适合课程实验、项目练手与算法原理验证。压缩包共 83 个文件,约 2.37MB,包含 C++ 源码与头文件、Visual Studio 工程配置、可执行程序、道路测试图像以及构建日志和中间产物,便于直接运行观察效果,也能对照工程结构梳理编译依赖。已有 2883 人学习下载,说明该主题具有较高关注度。读者可从中理解灰度化、高斯滤波、Canny 阈值设置、findContours 轮廓筛选、HoughLinesP 线段检测及最小二乘/RANSAC 拟合思路,并结合示例图像调试参数、分析漏检误检原因,为后续加入感兴趣区域、透视变换和实时优化打下基础。

1. 一段行车记录仪视频喂进去:这几十行 OpenCV 代码能给你什么

车道线检测属于那种「跑通比讲通更有说服力」的题目。一份基于 OpenCV 的车道线检测代码,把一段行车记录仪视频喂进去,屏幕上原本只有白线的路面被两段半透明色块盖住,中间过程还能一张张存成图看,这种即时反馈对刚入门的人特别重要。它没有模型权重、没有训练脚本,纯靠灰度化、高斯滤波、Canny 边缘、ROI 掩膜、霍夫变换和一次函数拟合把线「凑」出来,每一步都能单独调、单独看。

这套思路的价值在于可解释。模型出错你只能猜,而这条流水线出错,你能一眼定位到是 Canny 阈值太狠还是 ROI 顶点压低了。代价也很明显:遇到阴影、夜间、强反光和弯道,效果会掉得很快,所以它更像一块跳板——先用手写管线把问题看清楚,再决定往哪个方向补。

它适合两类人。一类是课程设计或自学练手,需要一份能跑通、改得动的完整代码;另一类是做辅助驾驶原型验证,想先用最轻的方式确认摄像头安装角度、ROI 区域和阈值范围是否合理,再决定要不要上深度学习方法。

2. 工程结构与技术选型:一帧画面要经过哪几道手

一份能长期改下去的车道线代码,最怕的就是所有逻辑塞进一个main.py。项目正文里没给目录约定,但按这个题目的复杂度,常见做法是按「预处理 → 检测 → 拟合 → 渲染」切成四层,入口只负责解析参数和分发。这样你在调 ROI 的时候,不需要在一千行里翻找。

2.1 目录结构:代码被拆成了哪几层

我一般会把管线拆成这样,lane/下的每个文件只对外暴露一两个函数,输入输出都是numpy.ndarray,中间不夹带全局状态:

lane-detection/ ├── main.py # 入口:--source 决定走图片还是视频 ├── config.json # 全部可调参数外置,改阈值不用动代码 ├── lane/ │ ├── __init__.py │ ├── preprocess.py # 灰度化、高斯模糊、Canny 边缘 │ ├── roi.py # 梯形掩膜,按比例生成顶点 │ ├── detect.py # HoughLinesP + 斜率分流 │ ├── fit.py # 左右车道拟合(一次/二次) │ └── render.py # 叠加、连线、写视频 ├── assets/ │ ├── test.jpg │ └── demo.mp4 └── requirements.txt

这里有个取舍值得说:detect.py和fit.py分开,是因为「找线段」和「把线段变成一条车道的数学表达式」是两件独立的事。很多人把 HoughLinesP 的结果直接平均一下就画线,调试时完全分不清是检测少了线还是拟合算歪了。分层之后,你可以把某一帧的 Hough 输出 dump 成 JSON,离线反复试拟合策略。

render.py单独拎出来的理由更实际:叠加颜色、透明度、是否画中间过程,这些是展示层的东西,改一次不该影响检测逻辑。真实项目里经常需要出两套图——一套给调试看全流程,一套给演示看干净结果。

2.2 技术栈:OpenCV 管像素,NumPy 管几何,其它都是胶水

依赖清单很短,requirements.txt里通常就三行:opencv-python、numpy,外加可选的matplotlib用来做中间结果对照。OpenCV 承担了全部像素级操作——cvtColor、GaussianBlur、Canny、HoughLinesP、fillPoly、addWeighted、VideoCapture/VideoWriter;NumPy 负责掩膜数组、斜率计算、polyfit拟合和端点求值。

Matplotlib 的位置要摆正。它的imshow默认按 RGB 解释三通道,而 OpenCV 读进来是 BGR,直接显示会出现「红蓝互换」的诡异画面。调试阶段如果要存对比图,我倾向直接用cv2.imwrite,视觉上所见即所得,不用记色彩空间转换。

版本口径上不用抠太细,opencv-python4.x 的 API 在HoughLinesP、Canny这些核心函数上很稳。真正会咬人的是VideoWriter的编码器,后面对应小节会专门讲。

2.3 函数签名先定死:一条从 BGR 到叠加图的调用链

动手写实现之前,先把函数签名钉住,比先写算法更省事。签名定了,主流程就能先跑通,每个函数先返回个黑图占位,再逐个替换成真实现:

# 约定:所有帧统一是 uint8 的 BGR 三通道,shape=(H, W, 3) def preprocess(frame: np.ndarray, kernel: int = 5) -> np.ndarray: """BGR -> 灰度 -> 高斯模糊,返回单通道 uint8 图""" def edge_detect(blur: np.ndarray, low: int, high: int) -> np.ndarray: """Canny 边缘,返回单通道二值图(0/255)""" def region_of_interest(edge: np.ndarray, vertices: np.ndarray) -> np.ndarray: """梯形掩膜,顶点形如 [[x, y], ...],返回同尺寸二值图""" def hough_lines(edge: np.ndarray, cfg: dict) -> np.ndarray: """返回 (N, 4) 的线段数组,每行是 x1, y1, x2, y2""" def fit_lanes(lines: np.ndarray, height: int) -> tuple: """按斜率分成左右两组,各拟合一次函数,返回 (left, right)""" def render(frame: np.ndarray, left, right, alpha: float = 0.3) -> np.ndarray: """在原帧上叠加半透明车道区域,返回新帧"""

这套签名里有两处是血泪经验。第一处是hough_lines的返回值:cv2.HoughLinesP返回的形状是(N, 1, 4),如果没检测到线会返回None,直接lines[:, 0]会抛异常。所以在函数内部统一压平成(N, 4)并做好空值兜底,调用方就能安心地写if len(lines) == 0: return frame。第二处是fit_lanes把height传进去,是因为车道线一般画到画面底部,端点依赖图像高度,不传就得用全局变量,后面一改分辨率就翻车。

3. 环境搭建与第一次跑通:先出图,再谈准确率

新手最容易犯的错误是一上来就追求「检测得准」,结果参数一个都不敢动。更顺的路径是先让它稳定输出一张图,哪怕线画得歪七扭八,只要整条链路是通的,后面的调参就是在已知坐标系里挪动。这一节按「装依赖 → 单图 → 视频 → 参数外置」四步走。

3.1 依赖安装与版本口径

用虚拟环境隔离,别把 OpenCV 装到系统 Python 里,这是常识但每年还是有人踩。三条命令到位:

# 建环境并激活 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 安装依赖,opencv-python 是包含常用算法的主包 pip install opencv-python numpy matplotlib # 验证一下能不能读到视频编码器 python -c "import cv2; print(cv2.__version__); print(cv2.VideoCapture('assets/demo.mp4').isOpened())"

最后那行验证很关键。如果isOpened()返回False,可能是文件路径不对,也可能是这台机器拿不到解码后端。图片模式不会暴露这个问题,但视频模式会直接给你一个空循环,看起来像代码有 bug,实际是环境问题。

如果你的场景是树莓派或者国产化开发板,opencv-python装起来会比较慢甚至失败,常见做法是换成opencv-python-headless(去掉 GUI 依赖),再配合cv2.imwrite出图看结果。

3.2 单张图片跑通:先把中间结果一张张存下来

先别碰视频。拿一张静态图,把每个阶段的输出写盘,这是定位问题最快的办法:

# debug_pipeline.py —— 只跑一帧,把每一步的中间结果都存下来 import cv2 from lane import preprocess, roi, detect frame = cv2.imread("assets/test.jpg") assert frame is not None, "图片没读到,先检查路径" h, w = frame.shape[:2] blur = preprocess(frame, kernel=5) cv2.imwrite("out/01_gray_blur.png", blur) edge = cv2.Canny(blur, 50, 150) cv2.imwrite("out/02_canny.png", edge) vertices = roi.roi_vertices(w, h) masked = roi.region_of_interest(edge, vertices) cv2.imwrite("out/03_roi.png", masked) # 把 ROI 顶点画回原图,肉眼确认梯形有没有压到车道 vis = frame.copy() cv2.polylines(vis, [vertices.astype("int32")], True, (0, 0, 255), 2) cv2.imwrite("out/00_roi_overlay.png", vis)

跑完之后看四张图。02_canny.png里车道线应该是连续的白线,如果断成一节节,先调 Canny 阈值而不是改后面。03_roi.png应该只剩下路面中间那条梯形区域,如果天空或者引擎盖还留着,说明顶点坐标算错了。

00_roi_overlay.png这张我强烈建议每次都看一眼。很多人 ROI 调了半天没效果,是因为顶点顺序传错了——fillPoly需要的是顺时针或逆时针的闭合多边形顺序,[[左上, 右上, 右下, 左下]],顺序一乱就变成一个奇怪的形状,掩膜结果完全不对。polyline最后那个astype("int32")也是必需的,浮点数组传给 OpenCV 的绘图函数会静默失败或抛类型错误。

3.3 视频模式与参数外置:把魔法数字赶出源码

单图跑通之后换视频,逻辑不变,只是外面套一层循环,把阈值参数从config.json读进来:

import cv2, json with open("config.json") as f: cfg = json.load(f) cap = cv2.VideoCapture(cfg["source"]) fps = cap.get(cv2.CAP_PROP_FPS) or 25 w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # mp4v 在多数平台可写,但写不出文件时先换 XVID + .avi 排查 writer = cv2.VideoWriter(cfg["output"], cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) assert writer.isOpened(), "VideoWriter 没打开,检查编码器或输出扩展名" while True: ok, frame = cap.read() if not ok: break out = pipeline(frame, cfg) # 内部就是 preprocess -> roi -> detect -> fit -> render writer.write(out) cap.release() writer.release()

fps那里有个or 25的兜底。部分视频容器读出来CAP_PROP_FPS是0,直接传给VideoWriter会得到一坨没法播的帧序列或者干脆失败。宽高也必须和输入帧严格一致,差一个像素写入就是空文件,这个问题后面避坑章会细说。

参数外置结构长这样,把canny、roi、hough三块分开,方便你只改一块的时候不动其它:

{ "source": "assets/demo.mp4", "output": "out/result.mp4", "canny": { "low": 50, "high": 150 }, "roi": { "top_ratio": 0.62, "bottom_ratio": 0.92, "top_width_ratio": 0.12, "bottom_width_ratio": 0.42 }, "hough": { "rho": 2, "theta_deg": 1, "threshold": 40, "min_line_len": 30, "max_line_gap": 150 } }

ROI 用比例而不是绝对像素,是为了兼容不同分辨率。top_ratio表示梯形上边距离画面顶部的高度占比,0.62 大致对应「地平线往下一点」,bottom_width_ratio是下边半宽占比。摄像头装得高,top_ratio就要调大;装得低,调小。

4. 核心管线拆解:从 BGR 到黄色拟合线的六个函数

管线本身不复杂,复杂的是每个环节的默认值为什么是那个数。下面按调用顺序拆,每个函数都给实现、参数说明和「调这个参数会发生什么」。

4.1 预处理:灰度化与高斯核的取舍

车道线检测不需要颜色信息,绝大多数实现第一步就转灰度。转灰度除了降计算量,还有个好处是把三通道的噪声压成一路,后面 Canny 只处理单通道:

def preprocess(frame, kernel_size=5): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # kernel_size 必须是正奇数,偶数会被 OpenCV 拒绝 blur = cv2.GaussianBlur(gray, (kernel_size, kernel_size), 0) return blur

kernel_size的经验值区间是 3 到 7。取 3 的时候细节保留多,但路面纹理和裂缝也会被当成边缘;取 7 的时候噪声压得干净,代价是远端的细车道线会一起模糊掉,Canny 那边就断线。城市道路我一般用 5,高速或者画面很干净的路段用 3。

高斯核的第三个参数sigmaX=0表示让 OpenCV 根据核大小自动算,通常不用手填。手填一个很大的 sigma 会让图像糊到失去边缘,是很典型的过度预处理。

这里唯一的坑是灰度化本身带来的信息损失。黄色和白色车道线在灰度图里的亮度接近,遇到黄色标线在暗色沥青上的场景,转灰度后对比度会掉得很厉害。想改善的话,常见做法是在灰度之外并行做一路 HSV 或 HSL 阈值分割,把黄白两色的掩膜和 Canny 结果做按位或,代价是多一组阈值要调。

4.2 Canny 双阈值:低阈值放进来,高阈值筛出去

Canny 的两个阈值分工是固定的:高于high的一定是边缘,低于low的一定不是,夹在中间的只有连到强边缘上才算数。这个滞后机制意味着low控制召回,high控制精度:

def edge_detect(blur, low=50, high=150): # 典型比例是 low = high / 2 ~ high / 3 return cv2.Canny(blur, low, high)

一开始不用纠结具体数值,记住「强边缘数量」这个观察指标更实用。跑一帧,看high调高之后白色像素是变少还是消失:如果整张图几乎全黑,high太高;如果路面纹理密密麻麻都是白点,low太低。

白天高速场景 50/150 通常能work,但阴天或者逆光下我只改low,把它降到 30 左右先把线捞回来,high保持在 150 防止噪声涌进来。反过来夜间场景,路灯下的路面噪点极多,high要提到 200 才干净。

还有个常被忽略的点:Canny 之前如果做过 ROI 掩膜,边缘会在掩膜边界处人为「断」一下,所以正确顺序是「先 Canny 再 ROI」,别反过来。顺序反了会在梯形边界上出现一条不存在的强边缘,霍夫就会把它检成一条线。

4.3 ROI 梯形掩膜:四个点砍掉天空和车头

ROI 的作用是把搜索范围压到车道所在的梯形区域。这一步能砍掉 70% 以上的干扰边缘,也决定了整个检测的上限——梯形画得不对,后面再调都是白费:

import numpy as np def roi_vertices(width, height, top_ratio=0.62, bottom_ratio=0.92, top_width_ratio=0.12, bottom_width_ratio=0.42): # 上边(远处)窄,下边(近处)宽,构成梯形 top_y = int(height * top_ratio) bottom_y = int(height * bottom_ratio) top_half = int(width * top_width_ratio) bottom_half = int(width * bottom_width_ratio) cx = width // 2 return np.array([[ [cx - top_half, top_y], # 左上 [cx + top_half, top_y], # 右上 [cx + bottom_half, bottom_y], # 右下 [cx - bottom_half, bottom_y], # 左下 ]], dtype=np.int32) def region_of_interest(edge, vertices): mask = np.zeros_like(edge) # 全黑底 cv2.fillPoly(mask, vertices, 255) # 梯形内填白 return cv2.bitwise_and(edge, mask) # 只留梯形内的边缘

top_ratio=0.62的含义是上边落在画面 62% 高度处。这个值跟着摄像头安装位置走:装在前挡风玻璃顶部往下拍,地平线一般在画面中间偏下,0.6 到 0.65 是合理区间;行车记录仪装得高(往下俯视角度大),地平线更靠上,可以取 0.55。

bottom_ratio=0.92留出底部 8% 不处理,是为了避开引擎盖和保险杠。很多教程直接用 1.0 画到画面最底,结果引擎盖边缘被检成一条水平线,拟合时把斜率全带歪。

dtype=np.int32是硬要求,fillPoly不接受float64。用比例算出来的坐标一定要显式转整型,否则在某些 OpenCV 版本上会直接抛异常。

4.4 HoughLinesP:参数表比代码更值得反复看

概率霍夫变换拿到的是「线段」而不是「无限直线」,这点对车道线特别合适,因为我们只需要画眼前这一段。函数本体就三行:

def hough_lines(edge, cfg): theta = np.deg2rad(cfg["theta_deg"]) lines = cv2.HoughLinesP( edge, rho=cfg["rho"], # 距离分辨率,像素 theta=theta, # 角度分辨率,弧度 threshold=cfg["threshold"], # 一条线至少需要多少票 minLineLength=cfg["min_line_len"], # 短于此的线段丢弃 maxLineGap=cfg["max_line_gap"], # 同一条线断开的容忍间距 ) # 空结果的形状处理,别让上层去判断 None return np.empty((0, 4), dtype=np.int32) if lines is None else lines[:, 0, :]

五个参数里,真正需要反复调的是后三个:

参数作用调大之后调小之后
threshold累加器最低票数线少但干净,远端易漏线多但杂乱,虚线碎段变多
minLineLength线段最短长度只留长实线,虚线全丢大量短噪点进来
maxLineGap允许的断裂间距虚线能被连成一条同一条线被切成好几段

rho=2、theta_deg=1是常见的起点。theta分辨率取 1 度意味着角度精度够用,再细就是徒增计算量。threshold=40在大部分 720p 素材上能出结果,分辨率越高,同样的线像素越多、票数越多,这个值就要跟着涨。

maxLineGap是对付虚线的主力。城市道路的虚线间隔大概几十像素,设成 100 到 150 能把间隔连起来;设太大(比如 300)会把相邻的两条不同车道线连成一条,那就彻底乱了。

4.5 左右车道聚类:按斜率分流,用中位数抗离群

霍夫出来的是一堆散线段,需要按斜率的正负把它们分成左右两组,再各拟合成一条线。分流阈值不能设成 0,因为接近水平的噪声线斜率也在 0 附近:

def fit_lanes(lines, height, k_thresh=0.3): left_pts, right_pts = [], [] for x1, y1, x2, y2 in lines: if abs(x2 - x1) < 1: # 竖直线,斜率发散,直接跳 continue k = (y2 - y1) / (x2 - x1) b = y1 - k * x1 if k < -k_thresh: # 图像坐标系 y 向下,左车道斜率为负 left_pts.append((k, b, x1)) elif k > k_thresh: # 右车道斜率为正 right_pts.append((k, b, x1)) def _fit(pts): if not pts: return None # 取中位数而不是均值,一条离群线拉不歪结果 k = float(np.median([p[0] for p in pts])) b = float(np.median([p[1] for p in pts])) y_bottom = height y_top = int(height * 0.62) # 和 ROI 上边保持一致 x_bottom = int((y_bottom - b) / k) x_top = int((y_top - b) / k) return (x_bottom, y_bottom, x_top, y_top) return _fit(left_pts), _fit(right_pts)

坐标系是这里最容易想反的地方。图像的原点在左上角,y 轴向下,所以左车道从画面左下往右上走,(y2-y1)是负的,斜率就是负的;右车道相反。如果你按数学坐标系去理解,会得到完全相反的分组结果。

用中位数而不是均值,是这套代码能不能稳的关键。一帧里只要出现一条长横线(前车车顶、护栏横梁),均值就被拉偏,画出来的车道线会整条斜掉;中位数对这种少量离群值几乎免疫。

y_top这里我刻意写成和 ROI 上边同一个比例,保证画出来的线不会超出掩膜区域,视觉上更整齐。

4.6 拟合与叠加:端点怎么算、透明度给多少

最后一步是把左右两条线之间的区域填成半透明色块。填充比单纯描两根线好看得多,也更像「车道」而不是「两根棍子」:

def render(frame, left, right, alpha=0.3, color=(0, 255, 0)): overlay = frame.copy() pts = [] if left: pts.append((left[0], left[1])) pts.append((left[2], left[3])) if right: pts.append((right[2], right[3])) pts.append((right[0], right[1])) if len(pts) >= 3: cv2.fillPoly(overlay, [np.array(pts, dtype=np.int32)], color) # alpha 越小越透明,0.5 以上会盖住原图细节 return cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0)

顶点顺序要构成一个不自交的多边形:左车道从下往上,右车道从上往下,拼起来正好是顺时针一圈。顺序错了会得到蝴蝶结形状的填充,画面上看起来像故障。

alpha=0.3是个视觉平衡点。高于 0.5,色块会盖住路面本来的标线,你想验证检测准不准的时候反而看不清了;低于 0.2,在亮色路面上几乎看不见。演示视频可以稍微调高到 0.4,调试截图建议 0.25。

addWeighted的公式是src1*alpha + src2*beta + gamma,两个权重加起来等于 1 时亮度不变,加起来大于 1 就整体偏亮,小于 1 就发暗。这里写1 - alpha是为了保持亮度平衡,改成固定 1.0 也能出图,但叠加区域会明显更亮。

5. 避坑与排查:线断了、线抖了、视频卡住的真实原因

这套管线跑通不难,难的是它「看起来在工作」但结果时好时坏。下面四条是我在调这类代码时反复遇到的,每条按现象、原因、解决写,能省掉大量瞎试的时间。

5.1 阴影下一段车道线直接消失

现象很典型:同一段视频,正常光照下左右线都检测得到,车开进树荫或者桥洞,右车道突然整段没了,色块只剩半边,画面看起来像检测挂了。

原因是灰度化把颜色信息压掉了。白色标线在阴影里的绝对亮度可能和旁边被阳光照亮的路面差不多,cvtColor之后两者数值接近,Canny 就找不到那条边界。这不是阈值调错了,是信息在预处理阶段就丢了。

解决思路是加一路颜色通道。常见做法是把原图转到 HSL,对亮度通道单独做一次equalizeHist均衡化,或者直接对黄、白两个色相区间做inRange得到颜色掩膜,再和 Canny 结果按位或:

hls = cv2.cvtColor(frame, cv2.COLOR_BGR2HLS) # 白色:亮度高、饱和度低;黄色:色相在 15~35 之间 white = cv2.inRange(hls, np.array([0, 200, 0]), np.array([255, 255, 60])) yellow = cv2.inRange(hls, np.array([15, 30, 120]), np.array([35, 255, 255])) color_mask = cv2.bitwise_or(white, yellow) combined = cv2.bitwise_or(edge, color_mask)

阈值要根据自己素材里的实际像素值微调,别直接抄。想快速定位该取多少,用取色工具在原图上点几个车道线像素,看 HSL 三个分量的实际数值范围。

5.2 检测结果里混进大量水平横线

现象是叠加图里突然冒出一条横跨整个画面的粗线,左右车道的拟合结果跟着整体歪掉,下一帧又恢复正常,非常玄学。

根因有两个。第一,HoughLinesP本身不区分方向,水平的护栏、前车尾部、路面接缝都会被检出来;第二,如果分流逻辑只判断了k > 0/k < 0,斜率接近 0 的横线会随机落进某一组,用均值拟合时一条就能把结果带偏。

解决分两层。检测层加一道过滤,斜率绝对值小于 0.3 的直接丢弃,这个阈值对应大约 17 度的倾斜,比真实车道线的角度小得多;拟合层把均值换成中位数,如 4.5 节那段代码所示。两层都做,才稳。

另外提醒一句,如果横线是稳定出现的(比如引擎盖边缘),说明 ROI 下边界压太低了,把bottom_ratio从 0.92 调到 0.85 更省事。

5.3 视频处理到一半卡住,输出文件是 0 字节

现象是脚本不报错,while循环看起来跑完了,但out/result.mp4打开是空的,或者播放器提示文件损坏。

原因基本都在VideoWriter的构造参数上。帧尺寸必须和writer.write()传进去的帧完全一致,宽高差一个像素就会静默丢弃所有帧;VideoWriter_fourcc(*"mp4v")在某些精简编译的 OpenCV 上不可用,isOpened()返回False但很多人忘了断言。

排查顺序是这样:先看writer.isOpened()的返回值,一定加assert;如果为False,把 fourcc 换成"XVID"、输出文件名改成.avi再试一次;如果还不行,检查帧尺寸是不是从CAP_PROP_FRAME_WIDTH/HEIGHT读的,读出来是 0 的话说明容器元数据有问题,改成读第一帧的frame.shape来定尺寸。

还有一种更隐蔽的情况:程序中途抛出异常退出,writer.release()没执行,文件头没写完整,看起来就是 0 字节。用try/finally把release()包起来,至少能保证已经写进去的帧是能播的。

5.4 换了分辨率之后 ROI 完全错位

现象是把 720p 的素材换成 1080p,参数一个字没改,结果 ROI 梯形跑到画面左上角去了,检测全废。

原因是顶点坐标被写成了绝对像素。[[600, 440], [680, 440], [900, 540], [380, 540]]这种写法在原始分辨率下完全正确,换一个尺寸就彻底失效,而代码里看不出任何问题,非常像黑匣子。

解决办法是把所有与位置相关的值都换成比例,在运行时乘以width/height,也就是 4.3 节那个roi_vertices函数的写法。同理,minLineLength、maxLineGap这类和像素长度相关的参数也该按分辨率缩放,简单点就按height / 720做一次系数乘法。

改完之后建议加一条打印,把实际算出来的顶点坐标打出来,再配合cv2.polylines画回原图看一眼。这个习惯我保持了挺久,比反复看输出视频猜问题所在快得多。

6. 二次开发:把 demo 改成能上车的原型

跑通之后想继续往下走,绕不开三件事:弯道处理、性能、怎么证明改得更好。这三件事的顺序有讲究,先有验证手段再谈改造,否则你永远不知道新参数是不是在瞎调。

6.1 弯道场景:把一次函数换成以 y 为自变量的二次多项式

直道用一次函数够了,弯道会出现「左右线在远处交叉」的错误,因为两条直线强行延伸必然相交。改法是换成二次多项式,并且注意自变量取y而不是x——车道线接近竖直,用x做自变量会数值不稳定:

def fit_quadratic(lines, side, height): """side: 'left' 或 'right',按斜率过滤后收集所有端点""" pts = [] for x1, y1, x2, y2 in lines: if abs(x2 - x1) < 1: continue k = (y2 - y1) / (x2 - x1) if (side == "left" and k < -0.3) or (side == "right" and k > 0.3): pts.extend([(x1, y1), (x2, y2)]) if len(pts) < 3: return None ys = np.array([p[1] for p in pts], dtype=np.float64) xs = np.array([p[0] for p in pts], dtype=np.float64) # polyfit 返回 [a, b, c],对应 x = a*y^2 + b*y + c return np.polyfit(ys, xs, 2)

得到系数之后,用np.polyval(coef, ys)在一条从画面底部到 ROI 上边的y序列上求x,再cv2.polylines连成曲线。别忘了按y排序,否则画出来是锯齿。这一步的代价是:直道上二次项系数接近 0,退化成一次函数,几乎无损;弯道上能画出平滑弧线,视觉和实际拟合误差都更好看。

6.2 性能与工程化:逐帧处理挪进独立线程

720p 下纯 CPU 跑这套管线,单帧大概几十毫秒,勉强够 25fps。如果想上更高分辨率或者同屏跑多路,读帧和算帧就该拆开:一个线程只负责cap.read()往队列里塞,主线程从队列取帧处理。队列长度设小一点(比如 2),满了就丢最旧的帧,宁可丢帧也不要让延迟越积越大。

更实际的一条优化是「跳帧检测、逐帧绘制」。检测每两帧做一次,中间那帧沿用上一次的拟合结果重新画,视觉上几乎看不出来,耗时直接砍一半。

6.3 验证方法:手工标注一小段,算 IoU 而不是靠眼睛

最后这条最容易被跳过。改完参数觉得「看起来更好了」是不可靠的,人眼对几帧的随机波动很不敏感。做法是挑 30 到 50 帧,手工把车道区域涂成掩膜存成 PNG,然后算预测掩膜和标注掩膜的 IoU:

def lane_iou(pred, gt): pred = pred > 0 gt = gt > 0 inter = np.logical_and(pred, gt).sum() union = np.logical_or(pred, gt).sum() return float(inter) / (union + 1e-6)

一批帧的 IoU 取平均,改一次参数跑一遍,数值涨了才算真的改好了。标注不用很精细,把两条线之间涂白就行,重点是让不同参数之间可比。

从那以后我每次调这类阈值参数都强制走一遍这个流程,哪怕只标 20 帧,也比盯着视频反复看「好像稳了一点」靠谱得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询