ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++ QT部署YOLOv8人脸检测:ONNX推理与界面实现

C++ QT部署YOLOv8人脸检测:ONNX推理与界面实现 简介一份基于C与YOLOv8的人脸检测完整源代码面向需要实现静态图片与动态视频人脸识别功能的开发者及研究人员尤其适合有一定C与深度学习基础的读者。项目采用QT搭建人机交互界面将深度学习模型与高效编程语言结合适用于智能监控、身份验证、人机交互等场景。资源包为zip格式共786个文件约10.79MB主要包含cpp源代码、h头文件、ui界面文件、qrc资源文件以及编译生成的obj、exe、pdb等文件同时提供YOLO模型配置yml文件与相关json配置可支撑二次开发与直接运行。目前已有102人学习下载。通过这份源代码可了解YOLOv8检测器在C中的集成方式、QT多线程检测框架及界面设计思路并基于现有模块扩展人脸跟踪、表情识别等功能为实际工程落地提供可参考的完整范例。1. 把人脸检测跑在C里比想象中多三道坎用 Python 调 YOLOv8 做检测几行代码就能出结果但当你拿到“C基于YOLOv8实现人脸图片检测以及人脸视频检测的项目源代码采用QT实现人机界面”这个标题时真正要做的不是把 Python 换成 C 重写一遍模型而是把训练好的模型导出成 C 能调用的推理引擎格式再解决预处理、后处理和多线程界面刷新这三件事。这个标题面向的是已经跑通 YOLOv8、但想在 Windows 上发布一个带界面的桌面工具的人比如毕业设计、公司内部考勤打卡、游客统计这类场景。它和纯 Python 方案最大的区别在于模型结构和训练过程仍然发生在 Python 一侧C 负责的是推理、渲染和工程化部署。下面从模型导出讲到 QT 界面收尾把你需要自己补完的关键环节按顺序拆开。2. 模型出口从 YOLOv8 到 ONNXC 能读懂的张量形状2.1 先看懂 YOLOv8 的检测头与 C2f 结构YOLOv8 的 backbone 里大量使用 C2f 模块它本质上是把输入特征图按通道拆成两路一路经过多个 Bottleneck 做特征重提取另一路直接跨接最后在输出维上拼接。相比 YOLOv5 的 C3 模块C2f 的梯度回传路径更分散同样参数下特征重用的效率更高所以很多人说“yolov8模型结构中c2f”是在精度和速度之间做折中。但对 C 部署的人来说C2f 不用自己实现ONNX 导出后它就是一组卷积、归一化和残差连接的组合。真正需要关心的是 Detect 检测头。YOLOv8 的 Head 在三个尺度上分别输出特征图每个位置预测一组边界框偏移和类别概率边界框回归采用 DFL 分布表示而不是直接回归坐标。最终 ONNX 输出的形状是固定的比如输入 640×640、模型是在 COCO 80 类上训练的输出就是[1, 84, 8400]如果只做人脸检测重新训练后类别数是 1输出就是[1, 5, 8400]。这里的 4 是边界框的 x1、y1、x2、y2后面的数字是类别数8400 是三个尺度的网格点总数80*80 40*40 20*20。写 C 后处理时输出维度不写死通过 ONNX Runtime 的GetOutputTypeInfo读出来这样换模型不用改代码。 import torch from ultralytics import YOLO model YOLO(yolov8n-face.pt) # 假设你训练好的单类人脸权重 results model.export(formatonnx, opset12, imgsz640)导出时留意opsetONNX Runtime 对低版本 opset 支持更稳opset 12 足够覆盖 C2f 里用到的算子。imgsz640决定模型输入分辨率后面 C 端必须按同样尺寸做缩放。2.2 推理引擎选型为什么常用 ONNX Runtime 而不是 LibTorchC 部署 YOLOv8 常见有三条路LibTorch 直接加载.pt、ONNX Runtime 加载.onnx、OpenVINO 加载.xml。LibTorch 需要把整个 PyTorch 依赖链带进安装包体积大且对推理卡顿问题没什么优化OpenVINO 在 Intel CPU 上确实快但它强绑硬件平台换到 NVIDIA 显卡或 ARM 设备就得重新适配。ONNX Runtime 是这三者里发行一致性最好的官方预编译包直接提供 C 头文件和 DLL跨平台性好CPU 和 GPU 都能跑。对于标题里这种“图片检测 视频检测 QT 界面”的桌面项目我的选择是 ONNX Runtime CPU 版起步如果摄像头分辨率和帧率压不住再切 OpenVINO 版本借用 CPU 指令集优化。// CMakeLists.txt 中关键依赖声明 find_package(OpenCV REQUIRED) find_package(onnxruntime REQUIRED) add_executable(face_detector main.cpp detector.cpp video_processor.cpp) target_link_libraries(face_detector PRIVATE opencv_core opencv_imgproc opencv_highgui onnxruntime)2.2.1 导出 ONNX 时必须确认的两个输出点第一个是输入名。Ultralytics 默认导出的输入名是images输出名是output0但实际以你拿到的模型为准。C 初始化 Session 时直接用session.GetInputName动态拿不要写死字符串。第二个是输出的布局。部分版本导出后输出是[1, 84, 8400]另一部分可能是[1, 8400, 84]这取决于是否开启simplify或转置优化。C 解析时先读 shape 的第三维大小和倒数第二维大小判断哪个是类别维避免一上来就写固定下标。python -m onnxruntime.tools.convert_onnx_models_to_ort face.onnx如果做极致 CPU 部署可以再用 ORT 的离线优化工具转成.ort格式这个文件加载速度更快但调试时不容易打印中间节点建议在项目稳定后再用。3. C 推理实现预处理、NMS、图片和视频检测怎么写3.1 图片检测的最小闭环从 Mat 到 float 数组C 跨语言读取模型后逻辑上分四步读图、letterbox 等比缩放、转 float 并归一化、推理后做 NMS。YOLOv8 训练时默认输入是 640×640预处理如果直接cv::resize拉伸人脸长宽比会失真检测框偏移严重。所以要先做 letterbox在图像底部或右侧补灰边保证输入尺寸满足要求。cv::Mat letterbox(const cv::Mat src, int target_w 640, int target_h 640) { int h src.rows, w src.cols; double ratio std::min(target_w * 1.0 / w, target_h * 1.0 / h); int new_w static_castint(round(w * ratio)); int new_h static_castint(round(h * ratio)); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); const int top_pad (target_h - new_h) / 2; const int bottom_pad target_h - new_h - top_pad; const int left_pad (target_w - new_w) / 2; const int right_pad target_w - new_w - left_pad; cv::Mat dst; cv::copyMakeBorder(resized, dst, top_pad, bottom_pad, left_pad, right_pad, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); return dst; }ratio是关键变量后处理的坐标还原时仍需要它。补边颜色用114, 114, 114是 YOLO 训练时的默认填充值如果训练时改过这里也要同步改。随后把cv::Mat里的像素按 CHW 顺序放到std::vectorfloat里BGR 通道转成 RGB再除以 255.0f 归一化到 [0, 1] 区间。std::vectorfloat input_image(1 * 3 * 640 * 640); cv::Mat float_image; letterboxed.convertTo(float_image, CV_32FC3, 1.0 / 255.0); for (int c 0; c 3; c) { for (int i 0; i 640 * 640; i) { input_image[c * 640 * 640 i] float_image.atcv::Vec3f(i / 640, i % 640)[2 - c]; // BGR - RGB } }这里循环遍历每个像素把 OpenCV 的 HWC 连续内存重排成 CHW。如果你用cv::dnn::blobFromImage也能做同样的事但它内部还做了一次缩放容易和 letterbox 的坐标还原混淆不如手动填充直观。推理调用本身只有几行Ort::RunOptions run_options; std::arrayconst char*, 1 input_names {input_name.c_str()}; std::arrayconst char*, 1 output_names {output_name.c_str()}; std::vectorOrt::Value output_tensors session.Run( run_options, input_names.data(), input_tensor.GetTensors().data(), 1, output_names.data(), 1);3.2 后处理代码解析从 8400 个候选框里留下人脸模型输出里有 8400 个候选框绝大多数置信度很低要先用conf_threshold过滤再做 IoU 非极大值抑制。YOLOv8 的输出头预测的是中心坐标加宽高但很多导出版本返回的是cx, cy, w, h需要转成x1, y1, x2, y2才能画框。以下代码兼容[1, 84, 8400]和[1, 8400, 84]两种布局std::vectorcv::Rect boxes; std::vectorfloat scores; std::vectorint class_ids; auto* raw_ptr output_tensors[0].GetTensorDatafloat(); auto shape output_tensors[0].GetTensorTypeInfo().GetShape(); bool transposed (shape[1] shape[2]); // 判断哪个维度是 8400 const int num_anchors transposed ? static_castint(shape[1]) : static_castint(shape[2]); const int num_classes transposed ? static_castint(shape[2]) - 4 : static_castint(shape[1]) - 4; for (int i 0; i num_anchors; i) { int offset transposed ? i * (num_classes 4) : i; float obj_conf transposed ? raw_ptr[offset 4] : raw_ptr[4 * num_anchors i]; // 人脸模型只有一个类别直接取 class 0 的分数 float class_conf transposed ? raw_ptr[offset 4 1] : raw_ptr[(4 1) * num_anchors i]; float final_conf obj_conf * class_conf; if (final_conf conf_threshold) continue; float cx transposed ? raw_ptr[offset] : raw_ptr[i]; float cy transposed ? raw_ptr[offset 1] : raw_ptr[num_anchors i]; float w transposed ? raw_ptr[offset 2] : raw_ptr[2 * num_anchors i]; float h transposed ? raw_ptr[offset 3] : raw_ptr[3 * num_anchors i]; // 坐标还原到原图 int left static_castint((cx - w / 2.0 - pad_left) / ratio); int top static_castint((cy - h / 2.0 - pad_top) / ratio); int right static_castint((cx w / 2.0 - pad_left) / ratio); int bottom static_castint((cy h / 2.0 - pad_top) / ratio); boxes.emplace_back(left, top, right - left, bottom - top); scores.emplace_back(final_conf); class_ids.emplace_back(0); } std::vectorint keep; cv::dnn::NMSBoxes(boxes, scores, conf_threshold, iou_threshold, keep);obj_conf在 YOLOv8 中和类别得分是解耦的输出张量里索引 4 之后全是类别分数。transposed判断逻辑里没有写死类别数所以后续如果想换成一个多类模型比如检测人脸加人体只需把class_conf的索引改成遍历所有类别即可。NMS 的iou_threshold建议设在 0.4 到 0.5 之间人脸重叠较多时设到 0.45 能稳住。3.3 视频检测把推理流程封装成可重复调用的类视频和图片本质上没有区别每一帧过一遍 3.1 和 3.2 的流程。但视频场景对性能敏感不能让每帧都重新构造 Ort::Session否则时间全耗在模型初始化上。常见的做法是把模型加载、预处理、推理、后处理封装成FaceDetector类构造时只初始化一次 Session对外暴露一个方法class FaceDetector { public: FaceDetector(const std::string onnx_path, float conf 0.25, float iou 0.45); std::vectorDetection detect(const cv::Mat frame); void setConfidence(float conf) { conf_threshold_ conf; } private: Ort::Session session_{nullptr}; cv::Size input_size_{640, 640}; float conf_threshold_{0.25}; float iou_threshold_{0.45}; };视频端做一次检测的循环长这样cv::VideoCapture cap(0); // 摄像头的 index或替换成视频文件路径 cv::Mat frame; FaceDetector detector(face.onnx); while (cap.read(frame)) { auto dets detector.detect(frame); for (const auto det : dets) { cv::rectangle(frame, det.rect, cv::Scalar(0, 255, 0), 2); } cv::imshow(face detection, frame); if (cv::waitKey(1) 27) break; // ESC 退出 }这里有个容易被忽略的点cap.read是阻塞式的它按视频源的帧率读取如果推理速度比帧率慢会导致视频播放越来越卡。处理方式是开一个cv::VideoCapture的缓冲队列或者干脆接受丢帧——比如上一帧还没推理完就丢弃当前帧保证界面看到的画面是实时推理结果而不是堆积的旧帧。setConfidence方法留着给 QT 界面的滑块参数用。4. QT 界面工程线程分离下的视频显示与人脸框绘制4.1 为什么推理必须放进 QThread而不是 QTimer 里硬跑QT 界面有一个天然约束主线程负责事件循环UI 重绘、按钮点击、鼠标消息全在这个线程里排队。如果直接在按钮槽函数里调用 3.3 的视频循环视频播放的每帧推理都会阻塞界面消息窗口会立即变成“未响应”状态。桌面摄像头 30 帧每秒每帧推理哪怕只有 30ms主线程也完全被占满。正确方案是把视频采集和推理放到工作线程通过信号槽把结果帧送回主线程。我习惯用QThread QObject的移动方式而不是继承QThread重写run()因为前者能更好地管理信号和槽的生命周期class VideoWorker : public QObject { Q_OBJECT public slots: void start() { running_ true; loop(); } void stop() { running_ false; } signals: void frameReady(const QImage image); private: void loop(); bool running_; }; QThread worker_thread; VideoWorker* worker new VideoWorker; worker-moveToThread(worker_thread); QObject::connect(worker_thread, QThread::started, worker, VideoWorker::start); // 关闭时先 stop 再 quitmoveToThread之后worker的所有槽函数都在worker_thread里执行。注意不要在start()槽里写死while (true)因为没有给事件循环喘息的机会stop()槽永远得不到执行。正确做法是在循环里让出差量用QThread::msleep(5)让信号队列有机会处理。4.2 用信号槽把 QImage 送到界面避开跨线程绘制问题OpenCV 的Mat不能直接跨线程交给 QT 绘制常见错误是先在子线程里构造QPixmap再发信号。QPixmap只能在主线程操作否则随机崩溃。稳妥的做法是在子线程里把Mat转成QImage让主线程的槽函数接收QImage并绘制到QLabel上。QImage在 QT 里是隐式共享的信号传递时只做引用计数不会发生深拷贝性能损失很小。QImage matToQImage(const cv::Mat mat) { cv::Mat rgb; cv::cvtColor(mat, rgb, cv::COLOR_BGR2RGB); return QImage(rgb.data, rgb.cols, rgb.rows, rgb.step, QImage::Format_RGB888).copy(); }这里的.copy()是必须要做的因为rgb.data在函数返回后就被释放了。如果mat是连续内存也可以直接替换成QImage::Format_BGR888来省掉一次cvtColor但 QT 的Format_BGR888在部分 Linux 平台渲染不稳定Windows 上没问题。为了跨平台我保留这个转换代价是每帧多一次颜色空间切换对实时性影响不大。4.2.1 图片检测的交互逻辑打开文件还是拖拽进入图片检测的 QT 交互比视频简单建议在界面上放置一个“打开图片”按钮和一个用于显示图像的QLabel。按钮槽函数里弹QFileDialog选择图片再调用FaceDetector::detect把检测结果画到原图上最后用同样的matToQImage转成QImage显示。因为图片是单次的不需要线程直接在主线程做推理是可以接受的用户能感知到界面短暂阻塞但体验尚可。如果想做得更顺滑可以放一个“检测中…”的状态字样或者在状态栏提示处理耗时。void MainWindow::onOpenImage() { QString path QFileDialog::getOpenFileName(this, 选择图片, , Images (*.jpg *.png *.bmp)); if (path.isEmpty()) return; cv::Mat img cv::imread(path.toLocal8Bit().toStdString()); auto dets detector_.detect(img); for (const auto d : dets) { cv::rectangle(img, d.rect, cv::Scalar(0, 255, 0), 2); } ui-imageLabel-setPixmap(QPixmap::fromImage(matToQImage(img))); }toLocal8Bit处理中文路径是 Windows 上最容易踩的坑。如果你用path.toStdString()中文文件名会变成乱码导致imread失败。将setPixmap改为setScaledContents(true)之前最好按原图尺寸设置QLabel的最小尺寸否则窗口大小变化时图像会被拉伸变形。5. 让项目能发布性能参数、崩溃排查与动态库分发5.1 性能调优的三个实测指标视频检测跑不顺时先分清是“采集慢”还是“推理慢”。用cv::getTickCount测量单帧耗时double t (double)cv::getTickCount(); auto dets detector_.detect(frame); double ms ((double)cv::getTickCount() - t) / cv::getTickFrequency() * 1000.0; qDebug() inference time: ms ms;三个最常见的影响项按优先级排imgsz从 640 降到 480推理耗时通常降到原来的 60% 以下conf_threshold从 0.25 调到 0.4能减少大量低质量框进入 NMS节省时间如果用的是yolov8s系列换成yolov8n权重精度损失在可接受范围但帧率提升明显。参数推荐起点调小的影响调大的影响conf_threshold0.25漏检减少误检增加误检减少漏检增加iou_threshold0.45相近框更容易被合并单人可能只出一个框重叠人脸框更可能同时保留出现重复框imgsz640推理变快小脸更容易漏检推理变慢小脸召回提升但显存/内存占用翻倍5.2 最常见的三个崩溃现场与对应处理QT 程序在别人电脑上启动就报qt_qpa_platform_plugin_path的错误提示原因是 plugins/platforms 目录没有随 exe 一起分发。把 QT 安装目录里的plugins整个拷贝到 exe 同级目录并确认platforms子目录下有qwindows.dll。如果不想拷贝整个 plugins在 main 函数第一行设置环境变量指定路径也可行。第二个高频崩溃发生在窗口关闭时。用户点了右上角 XVideoWorker还在循环里跑推理界面线程结束会把子线程对象销毁此时访问了已释放的Ort::Session直接段错误。关闭窗口时先调用worker-stop()再worker_thread.quit()最后worker_thread.wait()顺序反了就会出现“已停止工作”。在closeEvent里按这个顺序写是安全的。第三个问题指向 Visual C Redistributable。ONNX Runtime 的 DLL 依赖 MSVC 运行库目标机器没装 2019/2022 运行库程序可能连启动弹窗都没有事件查看器里能看到0xc000007b错误。发布包自带vc_redist.x64.exe静默安装或者用静态链接把运行时打进 exe。5.3 没有摄像头视频源时的验证方式开发阶段不想插摄像头可以读入一段本地测试视频代替。把VideoCapture的参数从 0 改成视频文件路径即可标题里的“人脸视频检测”用本地文件验证比摄像头更容易控制变量。验证时看三点人脸框是否稳定贴合、漏检率是否异常、长时间运行是否有内存持续增长。内存用任务管理器观察如果稳不住检查Ort::Value和cv::Mat有没有被new出来却忘记释放。顺带一提如果要在界面上显示实时帧率不要用QTimer去定时统计直接在frameReady槽里用QElapsedTimer统计信号间隔更准确还能顺便监控槽函数本身的处理耗时当QLabel的setPixmap成为瓶颈时这个数据会给你直观答案。项目走到这一步图片检测、视频检测、参数调节、发布分发就都闭环了。本文还有配套的精品资源点击获取
返回列表