简介:面向自动驾驶与智能交通场景的YOLOP全景驾驶感知部署方案,基于OpenCV的DNN模块实现,可同时完成交通目标检测、可驾驶区域分割和车道线检测三大任务,且仅依赖OpenCV库即可运行,无需安装任何深度学习框架。YOLOP在经典YOLO系列基础上针对全景驾驶感知进行了优化,这一部署包有效降低了此类模型的落地门槛。资源包共15个文件,压缩后约28.58MB,包含Python与C++两种语言的主程序、预训练ONNX模型权重、类别标签文件、说明文档及多张测试样张;说明文档覆盖环境配置和推理流程,样张图片则便于直观查看检测、分割与车道线输出效果。已有192人学习这一实现,适合希望快速上手OpenCV部署视觉感知模型的开发者,也适合自动驾驶方向的学生作为课设或研究参考。通过这套程序,可以清晰了解YOLOP的推理流程与输出结果,借助OpenCV统一完成预处理、推理和后处理,降低工程落地成本。
1. 用OpenCV部署全景驾驶感知网络YOLOP:三个驾驶感知任务,一份依赖
用OpenCV部署全景驾驶感知网络YOLOP,核心是把交通目标检测、可驾驶区域分割、车道线检测三个视觉任务合并到同一个推理单元里,并且整个程序不依赖PyTorch、TensorFlow这类深度学习框架。我过去做驾驶辅助工程验证时遇到最多的麻烦,不是模型效果不行,而是环境装不齐:Python版本、CUDA驱动、Torch版本稍微错一个,整个项目直接趴窝。YOLOP这套资源把权重提前转成ONNX,配合OpenCV DNN模块,C++和Python两端都能直接调用。对做智能交通、驾驶辅助验证的开发者来说,这份资源的实际价值就是“一份OpenCV依赖跑三个任务”:既能拿到交通参与者的目标框,又能出可驾驶区域掩码,还能看到车道线结果。下面按网络结构、ONNX导出、C++部署、Python部署、避坑记录的顺序把整条链路拆开。
2. YOLOP结构拆解与ONNX导出:三分支输出如何在DNN中落地
2.1 共享编码器加三分支头:检测、驾驶区域、车道线为何能同时出
YOLOP并不是简单地把三个模型串起来,而是让三个任务共享同一个特征提取主干。主干采用CSPDarknet结构,输入固定为640×640的RGB图像,经过多级下采样之后,特征图进入颈部网络,再从颈部向三个方向分流。一个方向走检测头,另一个方向走可驾驶区域分割头,还有一个方向走车道线分割头。因为三条分支共享了主干的大部分计算,所以推理开销要远低于“检测模型+分割模型”各跑一遍的做法。
检测头保留YOLO系列的经典设计,使用多尺度网格做目标定位和分类。在640×640输入下,检测分支会产生三个尺度的特征图,分别对应20×20、40×40、80×80的网格密度,小目标主要靠80×80这一层。可驾驶区域分割头和车道线分割头则负责像素级分类,输出的是与输入分辨率对应的二分类掩码。整体上,YOLOP把“目标在哪、哪里能开、车道边界在哪”三件事统一到了一个前向过程里,这也是它适合部署在实车验证场景的原因。
用OpenCV DNN模块来做推理,是因为DNN模块能把ONNX模型解析成自带的计算图结构,推理时不需要依赖原始模型的运行环境。只要OpenCV版本能完整解析ONNX算子,模型就能脱离PyTorch独立运行。这对现场调试和交付非常有价值,尤其是到了客户现场才发现机器上没有深度学习环境的时候,OpenCV的普适性能省掉大量沟通成本。
2.2 export_onnx.py转换脚本与关键参数
资源包里已经带了转换好的yolop.onnx,但如果你想换一张输入分辨率,或者重新训练权重之后再次导出,就需要走一遍export_onnx.py的流程。转换脚本的核心逻辑并不复杂,把PyTorch权重加载进来,固定输入尺寸,再调用torch.onnx.export导出成ONNX协议。
# export_onnx.py 的核心导出逻辑 import torch import torch.onnx from model import YOLOP # 模型定义来自YOLOP官方训练代码 model = YOLOP() ckpt = torch.load("yolop.pt", map_location="cpu") model.load_state_dict(ckpt.get("model", ckpt), strict=False) model.eval() # 固定输入尺寸,避免动态轴带来的兼容性问题 dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, "yolop.onnx", opset_version=11, # OpenCV DNN对opset 11支持最稳 input_names=["images"], output_names=["det_20", "det_40", "det_80", "drive_seg", "lane_seg"], dynamic_axes=None, # 关闭动态轴,固定形状推理 )这里有一个工程细节值得说明。opset_version=11是我在多个OpenCV DNN部署项目里测试下来最稳定的选择。opset太低,部分算子的表达能力不够;opset太高,比如13以上,OpenCV某些旧版本会报不支持或者解析错乱。
dynamic_axes=None也是刻意的。有人喜欢把宽高设置成动态轴,这样同一个ONNX可以吃任意分辨率输入。但在OpenCV DNN里,动态形状会带来很多麻烦:net.forward之前用户必须自己保证输入形状和计算图推导结果一致,一旦某个中间层因为形状不确定而推理失败,报错信息又很模糊,排错成本远大于收益。所以我一般固定640×640导出,部署时所有逻辑都按这个尺寸写。
2.3 输出张量形状核对:OpenCV关心的到底是哪几维
导出完成后,先用netron打开yolop.onnx看一下输出命名和形状。不同版本的导出代码,输出顺序可能有差别,不能想当然地认为第0个输出一定是检测结果。按照上面这段导出代码,五个输出的对应关系如下表。
| 输出名 | 输出形状 | 实际含义 |
|---|---|---|
| det_20 | 1×C×20×20 | 20×20网格的检测结果,每个网格点有多组anchor |
| det_40 | 1×C×40×40 | 40×40网格,负责中等大小目标 |
| det_80 | 1×C×80×80 | 80×80网格,负责小目标 |
| drive_seg | 1×2×640×640 | 可驾驶区域/背景的logits输出 |
| lane_seg | 1×2×640×640 | 车道线/背景的logits输出 |
其中det_20里的通道数C取决于类别数量和每个网格点预设的anchor组数。资源包里的bdd100k.names对应BDD100K道路场景数据集,类别是固定的那十几个道路目标类别。如果后续要换自己的数据集重新训练,类别数量变了,C这个维度也会变,部署代码里对应的解析逻辑必须同步修改。
提示:拿到一个陌生的ONNX时,先打印
net.getUnconnectedOutLayersNames(),再对照每个输出张量的size,确认顺序后再写后处理。这个习惯能避免后面很多莫名其妙的错位问题。
3. OpenCV DNN自定义层注册与C++部署:main.cpp实现路径
3.1 readNetFromONNX加载与OpenCV版本兼容边界
C++版本的主程序是main.cpp,核心依赖只有OpenCV。加载模型用readNetFromONNX,这个函数在OpenCV 4.0之后就有了,但不同版本对ONNX算子的覆盖度差异很大。YOLOP里的卷积、BatchNorm、上采样这些常规算子,在OpenCV 4.5以上基本都没问题。
如果你用的OpenCV版本比较老,加载或者第一次forward时可能碰到类似“Unknown layer type”的报错。最常见的是SiLU激活算子在旧版本里没有被解析。OpenCV把这类问题交给了自定义层注册机制,你可以在main.cpp开头补一段注册代码,把不认识的层类型映射到自己的实现。
// 自定义层注册示例:兼容旧版本OpenCV的Silu算子 #include <opencv2/dnn/dnn.hpp> class SiluLayerImpl CV_FINAL : public cv::dnn::Layer { public: explicit SiluLayerImpl(const cv::dnn::LayerParams& params) : cv::dnn::Layer(params) {} static cv::Ptr<cv::dnn::Layer> create(cv::dnn::LayerParams& params) { return cv::makePtr<SiluLayerImpl>(params); } void forward(cv::InputArrayOfArrays inputs, cv::OutputArrayOfArrays outputs, cv::OutputArrayOfArrays internals) CV_OVERRIDE { cv::Mat inp = inputs.getMat(0); cv::Mat expVal; cv::exp(-inp, expVal); cv::Mat sig = 1.0 / (1.0 + expVal); outputs.getMatRef(0) = inp.mul(sig); } }; CV_DNN_REGISTER_LAYER_CLASS(Silu, SiluLayerImpl);这个注册类做的事情就是把x * sigmoid(x)这个计算自己实现一遍。CV_DNN_REGISTER_LAYER_CLASS(Silu, SiluLayerImpl)宏告诉OpenCV DNN:当遇到名叫Silu的层时,用SiluLayerImpl来创建实例。实际项目中,我一般建议直接升级OpenCV到4.7及以上,绝大多数算子不用再手工补。但如果项目里OpenCV版本被其他模块锁死,自定义层注册就是唯一的后悔药。
主程序的加载和推理框架如下:
#include <opencv2/opencv.hpp> #include <opencv2/dnn/dnn.hpp> #include <iostream> using namespace cv; using namespace cv::dnn; int main(int argc, char** argv) { if (argc < 3) { std::cerr << "usage: ./yolop_demo <onnx> <image>" << std::endl; return -1; } Net net = readNetFromONNX(argv[1]); if (net.empty()) { std::cerr << "load onnx failed" << std::endl; return -1; } Mat image = imread(argv[2]); if (image.empty()) { std::cerr << "load image failed" << std::endl; return -1; } // 归一化、缩放、BGR转RGB Mat blob = blobFromImage(image, 1.0 / 255.0, Size(640, 640), Scalar(0, 0, 0), true, false); net.setInput(blob); std::vector<String> outNames = net.getUnconnectedOutLayersNames(); std::vector<Mat> outputs; net.forward(outputs, outNames); for (size_t i = 0; i < outputs.size(); i++) { std::cout << "output[" << i << "] name=" << outNames[i] << " dims=" << outputs[i].dims << std::endl; } return 0; }blobFromImage的第五个参数swapRB是true,这是很多新手翻车的地方。OpenCV读进来的图像默认是BGR排布,但YOLOP训练时用的是RGB顺序。如果这里设成false,相当于把红色通道和蓝色通道对调之后喂给了网络,检测置信度会明显下降,但程序又不会报错,属于典型的隐蔽问题。
3.2 目标检测后处理:从输出张量到NMS框
net.forward拿到的检测输出是原始网格数据,还不能直接画框。需要自己完成三件事:按anchor解码出中心点坐标和宽高、过滤低置信度框、做NMS去重。三个尺度的特征图都要处理,然后合并到一起。
// 对单层检测输出做解码 void decodeDet(const Mat& feat, float confThr, std::vector<Rect>& boxes, std::vector<float>& scores, std::vector<int>& classIds) { int C = feat.size[1]; int H = feat.size[2]; int W = feat.size[3]; int numClasses = C / 3 - 5; const float* data = feat.ptr<float>(0); for (int h = 0; h < H; h++) { for (int w = 0; w < W; w++) { for (int i = 0; i < 3; i++) { // 假设每个网格点有3组anchor,每组前5个值是cx,cy,w,h,objness int base = i * (numClasses + 5); float obj = data[base + 4]; for (int cls = 0; cls < numClasses; cls++) { float score = obj * data[base + 5 + cls]; if (score > confThr) { float cx = data[base] * 640.0f; float cy = data[base + 1] * 640.0f; float bw = data[base + 2] * 640.0f; float bh = data[base + 3] * 640.0f; boxes.push_back(Rect(cx - bw / 2, cy - bh / 2, bw, bh)); scores.push_back(score); classIds.push_back(cls); } } } data += C; } } }这里的C是255时,numClasses = 255 / 3 - 5 = 80,对应COCO风格的80类。但如果bdd100k.names里只有十几个类,那C就不是255,而是3 * (numClasses + 5)。所以千万别把这个数值写死在解码函数里,应该从输出张量维度现场算出来。解码完成后,把三个尺度的结果合并进同一个vector,统一调用NMSBoxes做最终筛选。
注意:解码出的坐标是相对于640×640输入图的,要展示到原始图像上,必须乘回缩放比例。scale_x = 原图宽度 / 640,scale_y = 原图高度 / 640。
3.3 可驾驶区域与车道线掩码的后处理输出
分割头的两个输出是logits,没有经过Sigmoid激活。OpenCV DNN不会替你做这一步。如果你直接把logits拿去和0.5做阈值比较,结果会是一片黑或者一片白,而且看起来毫无规律。
// 对分割logits做sigmoid,再取正类通道 void segPostprocess(const Mat& logits, Mat& mask) { Mat prob = logits.clone(); for (int i = 0; i < (int)prob.total(); i++) { float& v = prob.ptr<float>(0)[i]; v = 1.0f / (1.0f + std::exp(-v)); } // logits形状是1x2xHxW,正类在通道1 Mat ch0, ch1; std::vector<Mat> channels; int H = logits.size[2]; int W = logits.size[3]; Mat reshaped = prob.reshape(1, 2 * H); split(reshaped, channels); thresh = channels[1].reshape(1, H); // 阈值化,然后缩放到原图尺寸 threshold(thresh, mask, 0.5, 255, THRESH_BINARY); }不过这段代码依赖split配合reshape来拆分双通道,在C++里略绕。如果输出内存排布已经是连续NCHW,也可以直接用指针偏移去取通道1的数据,性能更好。分割后处理本身不复杂,但形状理解和内存排布一定要对,否则怎么就结果都不对。
4. Python版部署与可视化:main.py的推理管线
4.1 Python推理代码框架与DNN调用
Python版本的好处是后处理代码写起来更快,numpy天然支持批量计算。main.py整体思路和C++版本一致,先读模型,再预处理图像,然后forward取五个输出。
import cv2 import numpy as np net = cv2.dnn.readNetFromONNX("yolop.onnx") img = cv2.imread("images/0ace96c3-48481887.jpg") h, w = img.shape[:2] # 与C++版本一致:归一化、缩放、BGR->RGB blob = cv2.dnn.blobFromImage(img, 1/255.0, (640, 640), (0, 0, 0), swapRB=True) net.setInput(blob) outs = net.forward(net.getUnconnectedOutLayersNames()) def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) # 按导出脚本的命名顺序取输出 det_20, det_40, det_80 = outs[0], outs[1], outs[2] drive_logit = outs[3] lane_logit = outs[4] # 对分割logits做sigmoid drive_prob = sigmoid(drive_logit) lane_prob = sigmoid(lane_logit)outs是一个Python列表,每个元素对应一个输出张量。网络结构不变的情况下,列表顺序通常和导出时保持一致,但为了保险,我建议第一次运行时也打印每个元素的shape,和Netron里的输出对照一下。
numpy处理分割掩码比C++方便得多。drive_prob[0, 0]和drive_prob[0, 1]分别对应背景、可驾驶区域两个通道,直接对第二个通道做阈值就能得到掩码。
# 取正类通道,阈值化 drive_mask = (drive_prob[0, 1] > 0.5).astype(np.uint8) * 255 lane_mask = (lane_prob[0, 1] > 0.5).astype(np.uint8) * 255 # 缩放到原始图像尺寸 drive_mask = cv2.resize(drive_mask, (w, h)) lane_mask = cv2.resize(lane_mask, (w, h))4.2 结果叠加:三任务在一张图上出图
驾驶区域一般用半透明绿色覆盖,车道线用蓝色或红色线条,目标框直接用矩形画。这样一张图就能同时展示三个任务的输出,也方便截图做效果验证。
# 生成驾驶区域叠加层 overlay = img.copy() overlay[drive_mask > 0] = (0, 255, 0) # BGR绿色 result = cv2.addWeighted(img, 0.6, overlay, 0.4, 0) # 车道线以纯色形式叠上去 result[lane_mask > 0] = (255, 0, 0) # BGR蓝色 # 检测结果画框 for box, score, cls_id in zip(boxes, scores, class_ids): x1, y1, x2, y2 = [int(v) for v in box] cv2.rectangle(result, (x1, y1), (x2, y2), (0, 0, 255), 2) label = f"{class_names[cls_id]} {score:.2f}" cv2.putText(result, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) cv2.imwrite("result.jpg", result)这一段就是完整的图像处理项目闭环:读取、推理、后处理、可视化。实际调试时,我习惯把驾驶区域、车道线、检测框分别生成三张独立的输出图,方便逐项核对。确认每一路单独正确后,再合并到同一张图上。
4.3 与C++版本的差异点
Python版本和C++版本的推理核心完全一致,差异主要在内存操作和速度上。C++里处理NCHW张量需要在指针层面精确定位,代码写起来繁琐,但性能上限高。Python版借助numpy,后处理代码更简洁,调试时可以直接打印中间结果。
速度方面,如果只看前向推理,两者差距在个位数毫秒级别。真正拉开差距的是反复调用Python后处理时的解释器开销,尤其是检测框解析过程需要多重循环。如果检测目标很多,C++解码会明显占优。所以我通常的选型逻辑是:原型验证用Python,交付部署用C++。
5. 部署避坑记录:OpenCV DNN运行YOLOP的常见问题
5.1 加载报错:Unknown layer type与opset不兼容
现象:readNetFromONNX不报错,但第一次net.forward时抛异常,日志里出现“Unknown layer type Silu”或者“Unsupported operation”。
原因:OpenCV版本太老,对ONNX里的SiLU激活算子没有内置支持。YOLOP用了大量SiLU,老版本DNN在解析计算图时直接卡住。
解决:优先把OpenCV升到4.7以上,新版已经覆盖常见激活函数。如果工程环境锁死了OpenCV版本,就在main.cpp里通过自定义层注册机制补实现。补完之后建议拿一张小图先跑一次,确认没有其他层类型报错。
5.2 输出张量顺序不固定,分割结果串到检测上面
现象:检测框数量全是零,但分割掩码看起来像对象边缘;或者分割图出现奇怪的方框形状。
原因:五个输出张量的顺序和预期不一致。net.forward(names)返回的列表顺序由getUnconnectedOutLayersNames()决定,这个顺序来源于ONNX图里的输出节点排列,不一定等于导出脚本里写的那五个名字的顺序。
解决:第一次部署先打印输出名称和形状,按照打印结果去对应后处理代码,不要凭记忆按索引取。把五个输出分别命名成det0、det1、det2、drive、lane,逐个调试。
5.3 检测框位置偏移:坐标换算和BGR/RGB顺序问题
现象:置信度分数很高,框的大小也基本对,但框整体偏到目标左上角或者右下角。
原因:两个隐患叠加。一是解码的时候忘记把网格坐标乘回640,直接用归一化坐标画框;二是blobFromImage的swapRB参数设成了false,颜色通道顺序对不上,网络提取的特征产生系统性偏移。
解决:画框前必须乘scale_x和scale_y。另外把swapRB统一设为true,因为OpenCV读入图像是BGR,而深度学习模型训练几乎都按RGB设计。调试阶段可以用一张只含一个行人的图,框和行人完全重合后再批量跑。
5.4 分割掩码全黑或全白:漏掉Sigmoid的坑
现象:可驾驶区域掩码整个是白色的,车道线掩码整个是白色的,或者反过来全黑,阈值调高调低都没用。
原因:输出层给的是logits,数值范围在负几十到正几十之间。直接拿logits和0.5比,所有像素都大于0.5或者都小于0.5,结果自然全黑全白。
解决:先做sigmoid,把logits压缩到0到1之间,再取正类通道做阈值。还有一个常见错误是取了通道0而不是通道1,通道0是背景,取出来永远是反的。
5.5 bdd100k.names类别错位:名字和框内容对不上
现象:某个框把行人识别成了红绿灯,或者把车辆的类别全部错了一位。
原因:类别ID顺序和原训练配置不一致。bdd100k.names里的每行都对应一个类别ID,如果你自己重新训练过模型,或者从其他地方下载的权重和names文件不配套,ID就会错位。
解决:使用资源包自带的bdd100k.names,不要手动改动顺序。自己训练模型时,导出ONNX之后必须重新生成names文件,并且确认类别ID从0开始连续编号。
6. 验证与提速:让YOLOP在OpenCV DNN里跑得更稳
部署完成之后,至少做一次定量验证,别只看画出来的图“大概对”。我常用的办法是取同一张测试图,分别用PyTorch原模型和OpenCV DNN推理,把输出的第一个张量逐像素做差,计算最大值和平均值。
# 用numpy对比ONNX输出与原始PyTorch输出的差异 diff = np.abs(outs[0] - torch_out[0].detach().numpy()) print("max diff:", diff.max()) print("mean diff:", diff.mean())正常情况下,由于浮点累加顺序不同,平均差异会在1e-3量级。如果发现某个通道的差异突然变成1e-1甚至更大,说明OpenCV在解析某个算子时采用了不同的实现,需要进一步排查。
验证走完一遍后再谈提速。OpenCV DNN默认使用CPU推理,但在Jetson或者有CUDA的机器上,可以切换后端和计算目标:
net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)如果GPU显存吃紧,DNN_TARGET_CUDA_FP16配合半精度计算也能跑,代价是分割边缘可能出现轻微噪点,视觉验证时不容易察觉,定量验证时能看出来。CPU环境下,最直接的提速手段是把输入分辨率降到512或416,相应地重新导出ONNX,因为yolop.onnx是固定640×640的图,直接喂其他分辨率不会自动生效。
最后说一个我养成的习惯:从那以后我每次把YOLOP或者类似的多输出ONNX接到OpenCV DNN,都强制先打印输出名称、输出形状、第一个像素值,把验证放在预处理之前,确认图和模型对上了再继续往下优化。这套流程看起来多花了两分钟,实际省掉的是几小时找错位的时间,希望帮到你。
本文还有配套的精品资源,点击获取