拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于QT+OpenCV+YOLO+ONNX的桌面端目标检测部署实践

简介一套基于QT框架、OpenCV图像库、YOLOv8模型与ONNX运行时的目标检测示例代码包面向具备基础C和图像处理知识、希望快速搭建跨平台目标检测系统的开发者资源紧扣QT界面设计与OpenCV集成这一主线解决模型导出、图像格式转换、界面融合及部署优化等常见问题。压缩包仅10KB共4个文件包括md格式技术讲解文档、inscode工程文件、html辅助页面与gitignore规则文件结构简洁便于对照说明查看关键流程。已有119人学习浏览代码覆盖ONNX模型加载与推理、YOLO数据格式解析、QT主界面设计、图像转换以及后处理中的非极大值抑制NMS实现同时给出异步处理、多线程、GPU加速和量化优化等性能调优建议。该示例既能作为目标检测初学者理解QT与AI模型融合的入门参考也可用于课程设计、原型验证或在此基础上扩展自有模型与业务逻辑。 QT、OpenCV、YOLO、ONNX这四个词凑在一起就是一套非常典型的桌面端目标检测解决方案。我最近把一个检测项目从Python原型迁到了纯C环境用Qt做界面、OpenCV做图像处理、YOLO做检测模型、ONNX Runtime做推理引擎整个过程跑通之后发现这套组合比想象中顺手坑也确实不少。这篇文章把我从模型导出、依赖配置、推理封装到界面显示的全过程都记录下来代码可以直接抄走改改用适合正在折腾Qt桌面应用、想接入YOLO检测能力又不想依赖Python运行时的开发者。1. 这套组合的选型逻辑为什么是QTOpenCVYOLOONNX1.1 技术选型对比ONNX Runtime vs OpenCV DNN很多人拿到一个YOLO模型第一反应是用OpenCV自带的DNN模块加载因为简单几行代码就能跑起来。我也这么干过但实际项目里很快就遇到了几个问题一是OpenCV DNN对YOLO系列某些算子的支持不够及时尤其是YOLOv8以后的新结构经常要等OpenCV版本更新才跟得上二是DNN模块的性能优化相对保守同样的模型在ONNX Runtime上跑CPU推理速度往往能提升20%~40%GPU加速更是差出一截。换成ONNX Runtime之后体验明显不一样。模型文件统一走ONNX格式底层可以用CPU、CUDA、TensorRT多种执行器切换而且动态shape、量化模型INT8/FP16支持都更成熟。对于做桌面应用来说ONNX Runtime还有个好处是它就是一个普通的原生库C API非常干净不存在需要额外起Python子进程的问题部署的时候一个DLL或者so文件就解决了。1.2 整体架构与数据流向整个项目的核心思路很直接Qt负责跟用户打交道OpenCV负责图像数据的读取和绘制YOLO模型负责在ONNX Runtime里完成推理。实际运行流程我用一句话概括Qt触发检测请求OpenCV拿到图片并做预处理ONNX Runtime跑网络输出特征张量OpenCV再负责画框和展示结果。之所以把界面和推理分开是为了保证程序的可维护性和响应速度。如果直接在Qt的UI线程里跑YOLO推理一张图就要几百毫秒到一两秒界面会明显卡死。所以我在项目里单独抽了一个Detector类所有推理逻辑封装在里面UI线程通过信号槽跟推理线程通信。整体结构清晰后续换模型、换后端、加摄像头输入都只需要改局部代码不用动界面部分。1.3 这个方案能解决什么问题这套组合解决的核心痛点就是桌面软件内嵌检测能力这件事。纯Python方案做原型很舒服但交付给客户的时候要求人家装Python环境、装一堆依赖库体验非常糟糕。用QtC把整个应用编译成一个独立的exe带上必要的DLL双击就能跑这才是桌面产品该有的样子。其次Qt本身在Windows、Linux、macOS上都能编译同一个代码库可以快速出三个平台的版本对于需要多平台交付的检测工具来说性价比很高。2. 环境准备与模型导出先搭好地基2.1 开发环境清单这个项目对版本比较敏感我列一份我正在用的环境照着配基本不会踩大坑组件版本建议说明Qt5.15.2 或 6.5Qt 6对CMake支持更好但5.15的教程资源更多OpenCV4.8.0 或更高需要包含dnn模块建议自己编译或下载官方预编译包ONNX Runtime1.16.x 或更高需要选带CUDA的版本如果用GPUCMake3.20Qt 6强制要求CMakeQt 5也可以用qmakeVS 2019/2022 或 GCC 9-Windows用MSVCLinux用GCCPython Ultralytics3.9仅用于导出模型和测试OpenCV这里多说一句官方预编译的Windows包不带CUDA加速如果你需要用到CUDA版本的OpenCV就得自己用CMake重新编译步骤比较繁琐但网上教程很多。如果只是做常规图像读取、绘制、resize这些操作官方包完全够用。2.2 YOLOv8模型导出ONNX的关键参数ONNX Runtime跑的是ONNX格式模型所以第一步就是把训练好的YOLO模型从PyTorch格式导出来。我用的是ultralytics这个库导出命令非常简单pip install ultralytics yolo export modelyolov8n.pt formatonnx opset12 dynamicTrue也可以用Python脚本的方式from ultralytics import YOLO model YOLO(yolov8n.pt) model.export(formatonnx, opset12, imgsz640, dynamicTrue, simplifyTrue)这里有几个参数需要解释一下。opset12是指ONNX算子集的版本ONNX Runtime对opset 12以上的模型支持很成熟太低了有些算子导出不了太高了老版本的ONNX Runtime可能不认识。dynamicTrue表示允许动态输入尺寸也就是推理的时候不用固定死在640×640可以输入任意被32整除的尺寸灵活性更高。simplifyTrue会调用onnx-simplifier做一次计算图优化去掉一些冗余节点实测模型体积和推理速度都有改善。如果你是拿自定义数据集训练的模型导出方式完全一样只要把yolov8n.pt换成你自己的权重文件就行。导出的.onnx文件直接用Netron打开可以直观看到输入输出的张量形状后续写C代码时你就能清楚地知道要处理的数据结构。2.3 CMake工程配置项目构建我推荐用CMakeQt 5.15和Qt 6都支持得很好。下面是一个精简版CMakeLists.txt可以直接参考cmake_minimum_required(VERSION 3.20) project(YoloDetector) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(Qt5 COMPONENTS Widgets Core Gui REQUIRED) find_package(OpenCV REQUIRED) set(ONNXRUNTIME_ROOT D:/libs/onnxruntime-win-x64-1.16.3) add_executable(YoloDetector main.cpp MainWindow.cpp Detector.cpp ) target_include_directories(YoloDetector PRIVATE ${OpenCV_INCLUDE_DIRS} ${ONNXRUNTIME_ROOT}/include ) target_link_libraries(YoloDetector PRIVATE Qt5::Widgets ${OpenCV_LIBS} ${ONNXRUNTIME_ROOT}/lib/onnxruntime.lib ) # Windows下需要把ONNX Runtime依赖的DLL拷贝到运行目录 add_custom_command(TARGET YoloDetector POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${ONNXRUNTIME_ROOT}/lib/onnxruntime.dll $TARGET_FILE_DIR:YoloDetector )这套配置在Windows和Linux下都能跑Linux下只需要把onnxruntime-win-x64-1.16.3换成Linux对应的目录windows库名是.libLinux直接链接.so。注意一点ONNX Runtime的include目录和lib目录路径一定要写对而且64位程序必须搭配64位的ONNX Runtime库位数不一致会在链接阶段报一堆莫名其妙的错误。2.4 项目目录结构项目文件组织成下面这样非常清晰YoloDetector/ ├── CMakeLists.txt ├── main.cpp ├── MainWindow.h / MainWindow.cpp ├── Detector.h / Detector.cpp ├── models/ │ └── yolov8n.onnx └── resources/ └── test.jpg推理封装单独放一个Detector类好处是可以单独写单元测试也可以在没有界面的情况下用命令行工具验证模型效果等Verify好了再接界面排查问题快很多。3. 核心代码实现与解析3.1 封装ONNX Runtime推理类整个项目最关键的一个文件就是Detector类它负责加载模型、执行推理、返回检测结果。先看头文件// Detector.h #pragma once #include opencv2/opencv.hpp #include onnxruntime_cxx_api.h #include string #include vector struct Detection { int class_id; float confidence; cv::Rect box; }; class Detector { public: explicit Detector(const std::string model_path); ~Detector(); std::vectorDetection detect(const cv::Mat image, float conf_threshold 0.25, float nms_threshold 0.45); void setInputSize(int width, int height) { input_w_ width; input_h_ height; } private: cv::Mat letterbox(const cv::Mat src); std::vectorDetection postprocess(const float* output, int num_anchors, int num_classes, float scale, int pad_w, int pad_h, float conf_threshold, float nms_threshold); Ort::Env env_{ORT_LOGGING_LEVEL_WARNING, yolo_detector}; Ort::SessionOptions session_options_; std::unique_ptrOrt::Session session_; std::vectorint64_t input_shape_; std::vectorint64_t output_shape_; int input_w_ 640; int input_h_ 640; };构造函数里做模型加载和session创建最关键的是要把输入输出的shape信息读出来因为后面分配内存和reshape张量都要用// Detector.cpp #include Detector.h #include iostream Detector::Detector(const std::string model_path) { session_options_.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); session_options_.SetIntraOpNumThreads(4); // 如果使用GPU在这里添加CUDA执行器 // OrtCUDAProviderOptions cudaOptions; // cudaOptions.device_id 0; // session_options_.AppendExecutionProvider_CUDA(cudaOptions); session_ std::make_uniqueOrt::Session(env_, model_path.c_str(), session_options_); // 获取输入输出信息 Ort::AllocatorWithDefaultOptions allocator; auto input_name session_-GetInputNameAllocated(0, allocator); auto output_name session_-GetOutputNameAllocated(0, allocator); Ort::TypeInfo input_type_info session_-GetInputTypeInfo(0); auto input_tensor_info input_type_info.GetTensorTypeAndShapeInfo(); input_shape_ input_tensor_info.GetShape(); Ort::TypeInfo output_type_info session_-GetOutputTypeInfo(0); auto output_tensor_info output_type_info.GetTensorTypeAndShapeInfo(); output_shape_ output_tensor_info.GetShape(); std::cout 模型输入形状: ; for (auto dim : input_shape_) std::cout dim ; std::cout \n模型输出形状: ; for (auto dim : output_shape_) std::cout dim ; std::cout std::endl; }这里有个小细节如果模型是用dynamicTrue导出的input_shape_的前两维可能是-1动态维度后续创建输入张量时不直接用这个shape而是手动指定batch size1和实际的输入尺寸。我一般在调用detect函数时根据实际输入尺寸重新构造。3.2 推理主流程与letterbox预处理detect函数的核心逻辑分为四步预处理、构造输入张量、跑推理、后处理。下面是完整的实现std::vectorDetection Detector::detect(const cv::Mat image, float conf_threshold, float nms_threshold) { // 1. letterbox预处理 float scale 1.0f; int pad_w 0; int pad_h 0; cv::Mat resized letterbox(image, input_w_, input_h_, scale, pad_w, pad_h); // 2. 构造ONNX Runtime输入张量 cv::Mat float_img; resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0); // HWC - CHW cv::Mat chw_img; cv::dnn::blobFromImage(float_img, chw_img); std::vectorint64_t input_tensor_shape {1, 3, input_h_, input_w_}; size_t input_tensor_size 1; for (auto dim : input_tensor_shape) input_tensor_size * dim; Ort::MemoryInfo memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, (float*)chw_img.data, input_tensor_size, input_tensor_shape.data(), input_tensor_shape.size()); // 3. 推理 auto output_tensors session_-Run(Ort::RunOptions{nullptr}, input_names_.data(), input_tensor, 1, output_names_.data(), 1); const float* output_data output_tensors[0].GetTensorDatafloat(); auto output_info output_tensors[0].GetTensorTypeAndShapeInfo(); auto actual_output_shape output_info.GetShape(); int num_anchors actual_output_shape[1]; int num_classes actual_output_shape[2] - 4; // 4. 后处理 return postprocess(output_data, num_anchors, num_classes, scale, pad_w, pad_h, conf_threshold, nms_threshold); }这段代码有一个地方很容易出错cv::dnn::blobFromImage会把图像从HWC转成CHW并且自动做归一化缩放。这里因为我已经手动把像素值除以255了所以blobFromImage传的缩放因子就是1.0。如果你不想分两步也可以直接对原始图像调用cv::dnn::blobFromImage(resized, chw_img, 1.0/255.0)一步搞定省一行代码但可读性差一些。letterbox的实现在这里cv::Mat letterbox(const cv::Mat src, int target_w, int target_h, float scale, int pad_w, int pad_h) { float ratio std::min(1.0f * target_w / src.cols, 1.0f * target_h / src.rows); scale ratio; int new_w std::round(src.cols * ratio); int new_h std::round(src.rows * ratio); pad_w (target_w - new_w) / 2; pad_h (target_h - new_h) / 2; cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); cv::Mat canvas(target_h, target_w, CV_8UC3, cv::Scalar(114, 114, 114)); resized.copyTo(canvas(cv::Rect(pad_w, pad_h, new_w, new_h))); return canvas; }letterbox的作用是保持图像宽高比不变的前提下缩放到目标尺寸多余部分用灰色114填充。这样做的原因很简单YOLO训练的时候就是这么处理图像的推理时保持一致才能复现训练时的精度。如果直接把图片拉伸到640×640物体会变形检测精度会有可感知的下降。3.3 输出解码与NMS从张量到检测框YOLOv8的ONNX输出形状是[1, 8400, 84]COCO数据集80个类别844个坐标80个类别置信度。8400是三个不同尺度特征图上的锚点总数。后处理要做的就是把每个锚点上预测的4个坐标cx, cy, w, h还原到原始图像坐标系然后做置信度过滤和NMS。std::vectorDetection Detector::postprocess(const float* output, int num_anchors, int num_classes, float scale, int pad_w, int pad_h, float conf_threshold, float nms_threshold) { std::vectorcv::Rect boxes; std::vectorfloat scores; std::vectorint class_ids; for (int i 0; i num_anchors; i) { const float* row output i * (4 num_classes); float cx row[0]; float cy row[1]; float w row[2]; float h row[3]; // 找出置信度最高的类别 float max_score 0.0f; int max_class 0; for (int c 0; c num_classes; c) { float score row[4 c]; if (score max_score) { max_score score; max_class c; } } if (max_score conf_threshold) { // 还原到原始图像坐标去掉letterbox的padding int x1 static_castint((cx - w / 2 - pad_w) / scale); int y1 static_castint((cy - h / 2 - pad_h) / scale); int box_w static_castint(w / scale); int box_h static_castint(h / scale); boxes.emplace_back(x1, y1, box_w, box_h); scores.push_back(max_score); class_ids.push_back(max_class); } } // NMS去除重叠框 std::vectorint indices; cv::dnn::NMSBoxes(boxes, scores, conf_threshold, nms_threshold, indices); std::vectorDetection detections; for (int idx : indices) { Detection det; det.class_id class_ids[idx]; det.confidence scores[idx]; det.box boxes[idx]; detections.push_back(det); } return detections; }NMS这块我直接用了OpenCV的cv::dnn::NMSBoxes省得自己写。如果你的项目对性能要求极端苛刻可以考虑把NMS也放到GPU上做但对桌面应用来说CPU上的NMS通常足够快8400个框也就几毫秒的事。3.4 Qt界面集成文件选择、图片显示与检测结果绘制Qt这边的核心交互就三个选择一张图片、点击检测按钮、界面上显示画好框的结果图。文件选择用QFileDialog::getOpenFileName图片显示用QLabel::setPixmap关键代码如下void MainWindow::onOpenImage() { QString filePath QFileDialog::getOpenFileName( this, 选择图片, QDir::homePath(), Images (*.png *.jpg *.jpeg *.bmp)); if (filePath.isEmpty()) return; m_srcImage cv::imread(filePath.toStdString()); if (m_srcImage.empty()) { QMessageBox::warning(this, 错误, 图片打开失败); return; } showImage(m_srcImage); } void MainWindow::onDetect() { if (m_srcImage.empty()) { QMessageBox::warning(this, 提示, 请先选择图片); return; } // 如果图片较大先缩放到一个合适的宽度再检测 cv::Mat image m_srcImage; float max_width 1280.0f; if (image.cols max_width) { float ratio max_width / image.cols; cv::resize(image, image, cv::Size(int(image.cols * ratio), int(image.rows * ratio))); } std::vectorDetection results m_detector-detect(image); cv::Mat result_img image.clone(); for (const auto det : results) { cv::rectangle(result_img, det.box, cv::Scalar(0, 255, 0), 2); std::string label Class std::to_string(det.class_id) std::to_string(det.confidence).substr(0, 4); cv::putText(result_img, label, cv::Point(det.box.x, det.box.y - 5), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 0), 1); } showImage(result_img); } void MainWindow::showImage(const cv::Mat image) { cv::Mat rgb; cv::cvtColor(image, rgb, cv::COLOR_BGR2RGB); QImage qimg(rgb.data, rgb.cols, rgb.rows, rgb.step, QImage::Format_RGB888); QPixmap pixmap QPixmap::fromImage(qimg.copy()); // 自动缩放显示 ui-labelImage-setPixmap(pixmap.scaled( ui-labelImage-size(), Qt::KeepAspectRatio)); }这里有个非常容易踩的坑QImage构造的时候传入的是rgb.data但QImage默认不会复制数据而是直接引用这块内存。如果后续rgb被销毁QImage就成了野指针。所以qimg.copy()这步不能省必须强制复制一份数据。我之前就因为少了这一步程序一运行就随机崩溃查了半天才发现是内存生命周期的问题。如果想做实时视频流检测把onDetect里的cv::imread换成从cv::VideoCapture读帧再用QTimer定时触发检测逻辑完全一样。4. 性能优化与踩坑实录4.1 推理提速的几种手段ONNX Runtime的CPU推理已经优化得不错了但做实时检测时还是不够用这时候可以逐级优化优化方式预期提速实现成本说明开启多线程1.5~2倍低SetIntraOpNumThreads(4)模型简化10%~20%低导出时加simplifyINT8量化2~3倍CPU中需要量化数据集精度可能下降1%~2%CUDA GPU推理5~10倍中需要下载CUDA版ONNX RuntimeTensorRT推理10倍以上高仅限NVIDIA显卡配置复杂我的实际建议是如果你有一块NVIDIA显卡直接上CUDA版本的ONNX Runtime代码改动只有几行在session_options里加一个CUDA provider推理速度能从500ms直接降到50ms效果立竿见影。如果必须用CPU跑把模型做INT8量化再启用线程优化也能达到可用的实时性。4.2 我实际踩过的几个坑第一个坑是ONNX Runtime的DLL加载问题。Windows下编译通过、运行时报找不到onnxruntime.dll这是新手最容易撞的。解决方法是把onnxruntime.dll复制到exe同目录或者在CMake里加一个POST_BUILD命令自动拷贝我前面给的CMakeLists已经处理好了。第二个坑是输入张量的数据布局。OpenCV的Mat默认是HWC布局而ONNX Runtime要求CHW布局。如果忘了转换模型会输出一堆莫名其妙的检测框精度断崖式下降。cv::dnn::blobFromImage这一步不能省。第三个坑是Linux下Qt图形界面起不来报qxcbconnection: failed to initialize xrandr。这个问题通常出现在没有图形界面的服务器环境或者缺少xcb相关依赖库。解决办法是安装必要的依赖包或者在代码里加一个-platform offscreen参数让Qt以离屏模式运行适合做纯后台推理服务。第四个坑是关于Qt消息循环的。我一开始把耗时推理直接写在槽函数里结果点击检测按钮后界面整个卡住拖都拖不动。后来改成在槽函数里启动一个std::thread跑推理然后用信号把结果传回UI线程。这里要记住Qt的UI操作必须在主线程里做子线程里直接操作控件是未定义行为大概率会崩溃。4.3 常见问题速查表现象原因解决办法编译报找不到onnxruntime.hinclude路径配置错误检查CMake中的ONNXRUNTIME_ROOT路径确认指向include目录链接报LNK1104无法打开onnxruntime.liblib路径或位数不对确认使用了64位lib且路径正确运行报找不到DLL运行时依赖缺失将onnxruntime.dll、opencv_world4xx.dll等拷贝到exe同目录检测结果全是无意义的框输入张量布局错误确认使用blobFromImage完成HWC到CHW转换检测框位置偏移坐标还原时忘了减去padding检查letterbox的pad值是否传递正确模型加载失败ONNX Runtime版本太低升级ONNX Runtime或降低导出时的opset版本Qt界面卡死推理阻塞了UI线程将推理放到子线程通过信号槽回传结果程序退出时崩溃ONNX Runtime析构顺序问题确保Detector对象在Qt界面对象之前析构或调整成员声明顺序4.4 后续扩展方向这套骨架完全可以往多个方向扩展。摄像头实时检测只需要把图片输入换成cv::VideoCapture实例分割可以导出YOLOv8-seg的ONNX模型后处理里再加一个mask解码流程如果你训练了自己的自定义数据集比如鸟类识别、滑坡检测导出模型后C代码一行都不用改只要换模型文件和类别数量就行。做边缘设备部署的时候还可以把ONNX模型转成OpenVINO的IR格式在Intel平台上获得更好的性能。我自己在实际操作中最大的感受是这套工具链最值钱的部分是模型导出到C部署之间的那一段桥也就是预处理、张量布局转换、后处理坐标还原这几个环节。只要把这段逻辑吃透了换成任何YOLO变体、任何部署框架核心思路都是一样的。如果你在移植过程中遇到问题不妨把流程拆开排查先用Python脚本验证模型推理结果正常再单独测试C端的预处理和后处理是否与原图像对应上这样定位问题能快很多。最后分享一个调试小技巧在封装Detector类时加一个saveDebugImage开关把letterbox前后的图像、NMS前后的框都保存下来一目了然能看到每一步处理是否正确比打印一堆浮点数要直观得多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门