ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenCV部署YOLOP:目标检测、可驾驶区域分割与车道线检测

OpenCV部署YOLOP:目标检测、可驾驶区域分割与车道线检测 简介面向自动驾驶与智能交通场景的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_locationcpu) model.load_state_dict(ckpt.get(model, ckpt), strictFalse) model.eval() # 固定输入尺寸避免动态轴带来的兼容性问题 dummy_input torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, yolop.onnx, opset_version11, # OpenCV DNN对opset 11支持最稳 input_names[images], output_names[det_20, det_40, det_80, drive_seg, lane_seg], dynamic_axesNone, # 关闭动态轴固定形状推理 )这里有一个工程细节值得说明。opset_version11是我在多个OpenCV DNN部署项目里测试下来最稳定的选择。opset太低部分算子的表达能力不够opset太高比如13以上OpenCV某些旧版本会报不支持或者解析错乱。dynamic_axesNone也是刻意的。有人喜欢把宽高设置成动态轴这样同一个ONNX可以吃任意分辨率输入。但在OpenCV DNN里动态形状会带来很多麻烦net.forward之前用户必须自己保证输入形状和计算图推导结果一致一旦某个中间层因为形状不确定而推理失败报错信息又很模糊排错成本远大于收益。所以我一般固定640×640导出部署时所有逻辑都按这个尺寸写。2.3 输出张量形状核对OpenCV关心的到底是哪几维导出完成后先用netron打开yolop.onnx看一下输出命名和形状。不同版本的导出代码输出顺序可能有差别不能想当然地认为第0个输出一定是检测结果。按照上面这段导出代码五个输出的对应关系如下表。输出名输出形状实际含义det_201×C×20×2020×20网格的检测结果每个网格点有多组anchordet_401×C×40×4040×40网格负责中等大小目标det_801×C×80×8080×80网格负责小目标drive_seg1×2×640×640可驾驶区域/背景的logits输出lane_seg1×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::Ptrcv::dnn::Layer create(cv::dnn::LayerParams params) { return cv::makePtrSiluLayerImpl(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::vectorString outNames net.getUnconnectedOutLayersNames(); std::vectorMat 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::vectorRect boxes, std::vectorfloat scores, std::vectorint 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.ptrfloat(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 原图宽度 / 640scale_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.ptrfloat(0)[i]; v 1.0f / (1.0f std::exp(-v)); } // logits形状是1x2xHxW正类在通道1 Mat ch0, ch1; std::vectorMat 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), swapRBTrue) 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都强制先打印输出名称、输出形状、第一个像素值把验证放在预处理之前确认图和模型对上了再继续往下优化。这套流程看起来多花了两分钟实际省掉的是几小时找错位的时间希望帮到你。本文还有配套的精品资源点击获取
返回列表