OpenCV+Qt+YOLO实时目标检测系统实战:从环境搭建到避坑指南
简介一套基于OpenCV与Qt、集成YOLO模型的人体检测演示工程面向初阶计算机视觉开发者或C/Qt学习者解决视频流与静态图像中实时识别人物并框选的需求。压缩包共26个文件约4.96MB包含cpp源文件、h头文件、CMake构建脚本、ui界面定义、qrc资源清单以及png/jpg示例图、gif效果演示和说明文档目录结构清晰便于直接查看工程全貌。目前已有85人学习下载。资料内附README与介绍PDF可快速了解整体架构与核心流程工程采用OpenCV捕获与预处理、YOLO模型推理、Qt画布绘制框选的经典管线适合对照源码理解目标检测系统开发中的环境配置、推理集成与界面联调思路也可作为课程设计或入门实战的参考。1. 一套能跑起来的 OpenCV Qt YOLO 检测系统从摄像头到人形框选它替你趟完了集成的路做视觉检测项目最耗时间的往往不是算法本身而是把 OpenCV、Qt、YOLO 三个库拧到一个工程里时到处冒出来的版本冲突、线程卡死和坐标映射问题。这套基于 OpenCV Qt YOLO 的简单检测系统就是一个解压后能直接编译的完整工程它的目标非常单一从摄像头取帧用 YOLO 识别画面里的人再用 Qt 界面把每个检测框实时画出来。对正在做课程设计、毕业设计或者想快速拿到一个能改的 C 检测 Demo 的开发者来说它已经把环境配置、模型部署和 UI 集成的脏活做完了你只需要把注意力放在换模型和调参数上。特别是前期调研阶段与其自己从零搭工程不如先用这套系统跑通全流程再逐步替换成自己的模型和数据。2. 环境搭建与工程结构OpenCV、Qt、YOLO 三种库的分工与版本搭配2.1 为什么是这个组合OpenCV 当胶水Qt 当外壳YOLO 当大脑先说结论这套系统里 OpenCV 负责“图像读取、预处理、画框”Qt 负责“窗口和交互”YOLO 负责“目标检测”。三者各管一摊边界清晰替换其中任何一层都不影响另外两层。如果是纯 OpenCV 做行人检测最经典的是 HOG SVM但那个方案在光照变化、遮挡和不同穿着下泛化能力很差框的位置经常漂调试起来非常痛苦。YOLO 就省心很多用 COCO 预训练权重直接能检测 person模型推理一次把整张图上 80 类目标都找出来最后只需要过滤出你关心的那一类。Qt 则完全不用碰图像算法它只负责把 OpenCV 处理好的帧显示到 QLabel 上再用信号槽通知界面刷新。选择 OpenCV DNN 而不是单独装 ONNX Runtime是因为 OpenCV 本身就带了 DNN 模块读 ONNX 推理一条龙不用再引额外的推理库整个工程依赖更少跨平台移植也更简单。这种架构带来的一个隐性收益是如果以后想换检测模型比如从 YOLOv5 换成 YOLOv8只需要改 detector 模块的模型加载和输出解析代码Qt 界面一行都不用动。后面几章我会按这个思路把每一层拆开讲。2.2 版本搭配与安装我踩过 MinGW 和 MSVC 混用的亏初次搭这套环境时最容易翻车的不是 OpenCV而是 Qt 和编译器的组合。OpenCV 官方预编译包默认是 MSVC 编译的如果你在 Qt Creator 里新建了一个 MinGW 的 Kit链接时就会报一堆无法解析的外部符号。所以我的第一个建议优先统一用 MSVC 编译器Qt 也选择对应 MSVC 版本的预编译库。下面是我在这套系统上实测比较稳的组合组件推荐版本说明OpenCV4.5.4 或更高DNN 模块对 ONNX 的支持更完整Qt5.15.2 LTS对 MSVC 和 MinGW 都有官方预编译包CMake3.16 或更高识别 Qt5 和 OpenCV 的 find 模块稳定编译器MSVC 2019 64 位和 OpenCV 官方 Windows 包匹配安装时OpenCV 解压后把opencv/build/x64/vc15/bin加到系统 PATHQt 安装的时候勾选msvc2019_64组件。注意不要同时装多个 Qt 版本到 PATH 里否则find_package(Qt5)可能会找到旧版本。在 CMakeLists 里我习惯把 Qt 路径写死避免系统里其他环境变量干扰set(CMAKE_PREFIX_PATH C:/Qt/5.15.2/msvc2019_64)这行放在find_package之前强制 CMake 使用指定版本的 Qt。如果你用的是我的工程模板解压后第一件要做的就是根据你本机 Qt 安装路径改这一行。2.3 工程结构解析一个 Zip 里应该有哪些目录这套系统解压后的目录结构大致是这样的opencv-qt-yolo-detect-system/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── mainwindow.cpp │ ├── mainwindow.h │ ├── detector.cpp │ ├── detector.h │ ├── videoworker.cpp │ └── videoworker.h ├── models/ │ └── yolov5s.onnx ├── config/ │ └── params.yaml └── ui/ └── mainwindow.uidetector封装了模型加载、推理、后处理videoworker负责摄像头取帧并通过信号发到 Qt 主线程mainwindow只做界面显示和绘制检测框models放 ONNX 权重config/params.yaml用来调置信度阈值、输入尺寸和类名。这样分的目的是让每一部分都能单独测试detector可以脱离界面用命令行调videoworker可以脱离检测模块先验证取帧。对应的 CMakeLists 核心部分cmake_minimum_required(VERSION 3.16) project(detect_system) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) find_package(OpenCV REQUIRED) find_package(Qt5 REQUIRED COMPONENTS Widgets) add_executable(detect_system src/main.cpp src/mainwindow.cpp src/detector.cpp src/videoworker.cpp ui/mainwindow.ui ) target_include_directories(detect_system PRIVATE src) target_link_libraries(detect_system PRIVATE ${OpenCV_LIBS} Qt5::Widgets )CMAKE_AUTOMOC会让 CMake 自动处理信号槽的 moc 文件CMAKE_AUTOUIC会读取ui/mainwindow.ui并生成对应的ui_mainwindow.h。链接时Qt5::Widgets会自动带上核心和 GUI 模块OpenCV 的OpenCV_LIBS变量则包含了 core、imgproc、dnn、videoio 等库。config/params.yaml里维护了一份模型参数model_path: models/yolov5s.onnx input_size: 640 conf_threshold: 0.25 nms_threshold: 0.45 class_names: [person, bicycle, car, ...]这里class_names必须和模型训练时的类别顺序一致。COCO 的 80 类里 person 排在最前面所以许多人误以为“第 0 个输出一定是 person”这个假设在换模型后很容易出错后面避坑章节我会专门讲。3. 把 YOLO 模型接进 OpenCV DNNONNX 转换、预处理与后处理参数3.1 为什么要先转 ONNXOpenCV 只认 ONNX不认 ptYOLOv5 官方发布的是 PyTorch 权重.pt文件OpenCV DNN 读不了这个格式。最常见的部署路径是先用仓库自带的export.py把.pt转成.onnx再由readNetFromONNX加载。转换命令基本是这个样子python export.py --weights yolov5s.pt --include onnx --opset 11如果加了--simplify参数会调用 onnx-simplifier 对计算图做优化有时候能提升推理速度但个别版本在简化后反而会丢失动态尺寸信息导致 OpenCV 读模型失败。我的习惯是先不 simplify 跑通全流程再回来试优化版本。转换后可以先用 ONNX Runtime 或 Python 快速验证一次模型能不能输出预期张量再进入 C 工程。这样能避免把“模型转换坏了”和“C 代码写错了”两件事混在一起排查。3.2 预处理blobFromImage 参数不能随便抄OpenCV DNN 加载模型的代码非常短真正的坑在预处理和后处理。先看加载和推理的部分cv::dnn::Net net cv::dnn::readNetFromONNX(models/yolov5s.onnx);读进来后要对原始帧做 letterbox 缩放然后再转成 blobcv::Mat letterboxed letterbox(frame, cv::Size(640, 640)); cv::Mat blob cv::dnn::blobFromImage( letterboxed, 1.0 / 255.0, cv::Size(640, 640), cv::Scalar(0, 0, 0), true, false ); net.setInput(blob); std::vectorcv::Mat outs; net.forward(outs, net.getUnconnectedOutLayersNames());这里blobFromImage的六个参数分别是输入图、归一化缩放系数、网络输入尺寸、均值、是否交换 R 和 B 通道、是否裁剪。YOLOv5 训练时像素范围是 0 到 1所以scale用1/255.0OpenCV 读入的图像是 BGR模型需要 RGB所以swapRB truecrop false表示不裁剪配合前面已经做过的 letterbox 保证输入尺寸正好是 640x640。letterbox 函数是 YOLO 推理效果的关键cv::Mat letterbox(const cv::Mat src, cv::Size new_shape) { float r std::min(new_shape.width / (float)src.cols, new_shape.height / (float)src.rows); int new_w std::round(src.cols * r); int new_h std::round(src.rows * r); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); int dw new_shape.width - new_w; int dh new_shape.height - new_h; int left dw / 2; int right dw - left; int top dh / 2; int bottom dh - top; cv::Mat dst; cv::copyMakeBorder(resized, dst, top, bottom, left, right, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); return dst; }r是缩放比例取宽高两个方向上的最小值保证短边缩放后正好等于 640长边等比缩放后剩余空间用灰边填充。填充值 114 是 YOLO 训练时常用的 padding 颜色。这里的left/top就是后处理还原坐标时需要用到的偏移量不少项目框不准就是少了这两个变量。3.3 后处理85 维向量解析、置信度过滤与 NMSYOLOv5 的 ONNX 输出通常是[1, 25200, 85]其中 25200 是三种尺度特征图上的候选框总数85 代表x, y, w, h, objectness, 80 个类别置信度。后处理要做的事就是把 25200 个候选框里没用的滤掉再做 NMS。先提一句YOLOv8 的输出就不是这个结构v8 是[1, 84, 8400]没有 objectness 那一维解析方式完全不同。所以下面的代码只针对 YOLOv5也是这套系统默认的模型格式。const int num_classes 80; const float conf_threshold 0.25; const float nms_threshold 0.45; std::vectorcv::Rect boxes; std::vectorfloat confidences; std::vectorint class_ids; for (const cv::Mat out : outs) { cv::Mat output out.reshape(1, out.total() / (4 1 num_classes)); for (int i 0; i output.rows; i) { const float* row output.ptrfloat(i); float obj_conf row[4]; if (obj_conf conf_threshold) continue; for (int c 0; c num_classes; c) { float class_conf row[5 c]; float final_conf obj_conf * class_conf; if (final_conf conf_threshold) { float x_center row[0]; float y_center row[1]; float w row[2]; float h row[3]; // 还原到原始图像坐标注意要减去 letterbox 的 pad int left (x_center - w / 2 - pad_x) / gain; int top (y_center - h / 2 - pad_y) / gain; int right (x_center w / 2 - pad_x) / gain; int bottom (y_center h / 2 - pad_y) / gain; left std::max(0, left); top std::max(0, top); right std::min(src_width, right); bottom std::min(src_height, bottom); boxes.push_back(cv::Rect(left, top, right - left, bottom - top)); confidences.push_back(final_conf); class_ids.push_back(c); } } } } std::vectorint indices; cv::dnn::NMSBoxes(boxes, confidences, conf_threshold, nms_threshold, indices);reshape(1, rows)会把输出扁平化为rows × cols的二维矩阵out.total()是所有元素个数除以85就是有效的候选框数量。obj_conf * class_conf是 YOLO 官方的置信度组合方式比只用其中一个更符合模型训练语义。NMS 的nms_threshold建议保持 0.45 左右调太高会出现同一个行人被画两个框调太低会把挨得近的两个人合并成一个框。pad_x和pad_y就是 letterbox 函数里计算出的left和topgain就是缩放系数r。如果你把这个信息存在 detector 的成员变量里后处理时就能直接复用。还原后一定要做边界裁剪否则检测框靠近画面边缘时容易越界Qt 界面绘制时甚至可能崩溃。4. 用 Qt 搭建实时检测界面视频帧渲染、检测框叠加与线程模型4.1 视频采集不能放 UI 线程QThread 与信号槽的配合方式实时检测系统最容易犯的错误是把cv::VideoCapture::read()放进 UI 线程。read()是阻塞的摄像头帧率只有 30fps 时UI 线程会被卡住窗口拖不动、按钮点没反应。最稳的做法是让摄像头采集在一个独立线程里跑每读出一帧就通过信号槽发给主线程。我一般用QObject moveToThread而不是继承 QThread 来写工作线程。核心代码是这样的class VideoWorker : public QObject { Q_OBJECT public: explicit VideoWorker(QObject* parent nullptr) : running_(false) {} public slots: void start() { cv::VideoCapture cap(0, cv::CAP_DSHOW); if (!cap.isOpened()) { emit errorOccurred(camera open failed); return; } running_ true; while (running_) { cv::Mat frame; cap frame; if (frame.empty()) continue; emit frameReady(frame.clone()); QThread::msleep(10); } cap.release(); } void stop() { running_ false; } signals: void frameReady(const cv::Mat frame); void errorOccurred(const QString message); private: std::atomicbool running_; };在MainWindow构造函数里做线程连接VideoWorker* worker new VideoWorker; QThread* captureThread new QThread(this); worker-moveToThread(captureThread); connect(captureThread, QThread::started, worker, VideoWorker::start); connect(worker, VideoWorker::frameReady, this, MainWindow::onFrameReady); connect(worker, VideoWorker::errorOccurred, this, MainWindow::showError);cv::CAP_DSHOW是 Windows 下的 DirectShow 后端打开摄像头的速度比默认后端快很多在 Linux 上可以改成cv::CAP_V4L2。frame.clone()这一步不能省因为VideoCapture内部会复用 Mat 缓冲区不深拷贝的话发送到主线程的数据可能在下一帧读取时被覆盖。msleep(10)是为了把采集频率控制在 100fps 以内给主线程留出处理时间实际按需求改。4.2 cv::Mat 转 QImageBGR、步长和对齐OpenCV 的帧不能直接给 QLabel 显示要先转成 QImage。这里的坑有三个颜色通道顺序、每行字节数、内存生命周期。我常用的转换函数QImage cvMatToQImage(const cv::Mat mat) { if (mat.type() CV_8UC3) { return QImage(mat.data, mat.cols, mat.rows, mat.step, QImage::Format_BGR888).copy(); } else if (mat.type() CV_8UC1) { return QImage(mat.data, mat.cols, mat.rows, mat.step, QImage::Format_Grayscale8).copy(); } return QImage(); }Format_BGR888是 Qt 5.14 之后才有的格式可以直接对应 OpenCV 的 BGR 通道布局不需要先做cvtColor。如果你的 Qt 版本比较老只能用Format_RGB888那就得先调用cv::cvtColor(mat, mat, cv::COLOR_BGR2RGB)否则图像会偏蓝或偏红这种色差很多人第一眼看不出问题打印像素值才能发现通道反了。mat.step这个参数很容易被忽略。OpenCV 每行像素可能有额外对齐字节如果直接用mat.cols * mat.channels()来计算bytesPerLine显示出来的图像会出现斜向撕裂。.copy()必须保留它保证了 QImage 拥有独立的数据段不会因为 Mat 被释放而失效。4.3 画框方式在原始帧上画完再整幅缩放这套系统默认的做法是在 OpenCV 的原始帧上把检测框和标签画好再转成 QImage 显示。这样有一个好处所有坐标都在同一坐标系里不需要在 Qt 的绘制事件里重新映射。void MainWindow::onFrameReady(const cv::Mat frame) { cv::Mat display frame.clone(); std::vectorcv::Rect boxes; std::vectorfloat confs; std::vectorint ids; detector_-Detect(display, boxes, confs, ids); for (size_t i 0; i boxes.size(); i) { cv::rectangle(display, boxes[i], cv::Scalar(0, 255, 0), 2); std::string label std::to_string(ids[i]) std::to_string(confs[i]); cv::putText(display, label, cv::Point(boxes[i].x, boxes[i].y - 6), cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(0, 255, 0), 2); } QImage image cvMatToQImage(display); QPixmap pixmap QPixmap::fromImage(image) .scaled(ui-videoLabel-size(), Qt::KeepAspectRatio); ui-videoLabel-setPixmap(pixmap); }把检测框画在原始帧上再整体缩放绘制结果不会出现坐标错位。如果 QLabel 的scaledContents属性被打开了QLabel 会对 pixmap 再做一次缩放叠加两次缩放会降低清晰度所以记得把这个属性关掉。如果以后要做鼠标交互比如点击某个检测框查看信息就需要做反向映射把 QLabel 上的像素坐标转换回原始图像坐标。因为 pixmap 是等比例缩放的公式很简单double scale std::min((double)ui-videoLabel-width() / frame.cols, (double)ui-videoLabel-height() / frame.rows); int imgX (clickX - offsetX) / scale; int imgY (clickY - offsetY) / scale;offsetX和offsetY是保持宽高比时黑边占掉的偏移量。这个功能不影响检测但如果你在资源基础上做二次开发提前把这个映射函数封装好后面会省很多事。5. 避坑指南Qt 版本混用、linuxfb 缺失、检测框偏移与推理延迟5.1 编译期报错cannot mix incompatible Qt library现象CMake 配置没问题但编译或链接时报fatal: cannot mix incompatible Qt library (version ex50601) with this library后面还跟着一串版本号。原因系统里存在多套 Qt 库比如 Qt Creator 自带了 Qt 5.9环境变量 PATH 里又指向 Qt 5.15.2。CMake 的find_package(Qt5)找到的是一套库链接器实际链接的却是另一套两套库的 ABI 不兼容。解决在 CMakeLists 顶部显式指定 Qt 路径set(CMAKE_PREFIX_PATH C:/Qt/5.15.2/msvc2019_64) find_package(Qt5 REQUIRED COMPONENTS Widgets)同时把 PATH 里其他 Qt 的bin目录临时移除清空 build 缓存重新配置。我一般还会在 CMake 里加一句message(STATUS Qt version: ${Qt5_VERSION})确认实际找到的是哪一个版本。5.2 运行期崩溃Could not find the Qt platform plugin linuxfb现象程序在无桌面环境的 Linux 板卡上运行报错qt.qpa.plugin: Could not find the Qt platform plugin linuxfb紧接着进程退出。原因Qt 的 platform 插件没有部署到可执行文件所在目录或者系统没有设置QT_QPA_PLATFORM_PLUGIN_PATH。linuxfb是 Qt 在 Linux 帧缓冲环境下使用的插件嵌入式板子上很常见。解决把 Qt 安装目录下plugins/platforms文件夹整体复制到可执行文件的同级目录确保程序运行时能找到插件。如果启用 linuxfb 平台还需要设置export QT_QPA_PLATFORMlinuxfb export QT_QPA_PLATFORM_PLUGIN_PATH./platforms交叉编译时这个插件必须由目标系统的 Qt 库生成不能从开发主机上直接拷贝否则会继续报 Qt 库版本不匹配。5.3 检测框偏移和漂移letterbox 还原坐标少算了 pad现象模型能检测到人但框的位置明显偏向右下方尤其在画面边缘的时候框和人完全对不上。原因预处理用了 letterbox把原始图像缩放后填充了灰边但后处理还原坐标时只按缩放比例gain转换没有减掉填充偏移pad_x和pad_y。模型在 640x640 的推理图里输出的中心点坐标是包含灰边偏移的直接按原图缩放到到的位置自然错位。解决在 detector 里把 letterbox 计算出的pad_x、pad_y、gain保存成成员变量后处理时先减偏移再除增益int left (x_center - w / 2 - pad_x) / gain; int top (y_center - h / 2 - pad_y) / gain; int right (x_center w / 2 - pad_x) / gain; int bottom (y_center h / 2 - pad_y) / gain;还原之后用图像宽高做一次裁剪防止检测框超出边界。这个问题很隐蔽因为它不影响推理结果只影响框的绘制位置看起来像“模型感知不准”实际上只是坐标还原少了一步。5.4 推理慢到不可用OpenCV DNN 默认 CPU 推理现象YOLOv5s 在普通 CPU 上每帧推理要 200 到 300 毫秒画面卡成 PPTCPU 占用率却很高。原因OpenCV 的 DNN 模块默认用 CPU 推理没有启用 CUDA 后端而官方预编译的 OpenCV 通常不带 CUDA 支持所以即使机器有 NVIDIA 显卡也不生效。解决如果不想重新编译 OpenCV最直接的办法是把模型换成算力更小的 YOLOv5n或者把输入尺寸从 640 降到 416。如果必须用大模型就需要自己编译 OpenCV在 CMake 时开启WITH_CUDA和OPENCV_DNN_CUDA然后在代码里告诉网络使用 CUDAnet.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA); net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA);注意在推理前先调用cv::cuda::getCudaEnabledDeviceCount()判断一下当前环境是否支持否则在无 GPU 的机器上直接设这两行会导致异常。5.5 界面卡死把摄像头读取放进了 UI 线程现象程序启动后窗口正常出现但摄像头画面一出来窗口拖动就变成白框点击按钮没有响应CPU 占用率接近单核满载。原因VideoCapture::read()是阻塞调用摄像头帧率低时UI 线程被卡在读取循环里Qt 的事件循环无法处理重绘、鼠标和按键消息。解决用第 4 章的VideoWorker moveToThread把采集丢到后台线程主线程只负责接收frameReady信号并做显示。如果一定要用std::thread也要把读到的帧放到互斥锁保护的队列里再用QTimer定时取出不能在子线程里直接操作任何 QWidget。这条是很多新手项目挂在半路的直接原因也是这套系统选择 QThread 方案的根本出发点。6. 进阶技巧把内置 YOLO 权重换成自己的模型三步就能验证换模型是这套检测系统最常见的二次开发需求。假设你已经在 YOLOv5 上训练了一个检测口罩的模型现在要把它塞进这套 Qt 工程里。第一步把训练好的best.pt转成 ONNXpython export.py --weights best.pt --include onnx --opset 11转换时如果训练输入尺寸不是 640加上--imgsz 416并同步修改params.yaml里的input_size。第二步把生成的best.onnx放进models目录替换原来的yolov5s.onnx。第三步修改detector.cpp里的类别数和类名const int NUM_CLASSES 2; std::vectorstd::string class_names {with_mask, without_mask};conf_threshold和nms_threshold可以先不动但换完权重后一定要用一张测试图单独跑一次 DNN确认输出坐标正常再接 Qt 界面。常见的做法是写一个临时命令行入口只加载模型、读一张图片、打印检测框坐标和置信度跑通了再回到MainWindow调试。换模型最容易翻车的点是新模型的输出结构和旧模型不一致。YOLOv5 的 ONNX 输出是[1, 25200, 85]YOLOv8 输出是[1, 84, 8400]需要按版本调整后处理代码。从那以后我每次换模型都强制走一遍固定流程先转 ONNX、再用命令行跑一张图确认输出 shape 和坐标、最后才接 Qt 界面。这套顺序帮我避开了好几次“模型没问题、界面显示才炸”的乌龙。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →