简介这份资源面向嵌入式开发与计算机视觉方向的开发者提供在树莓派上基于Qt、OpenCV与C部署YOLOv5-Lite深度学习推理的完整工程实践。内容围绕边缘计算场景展开涵盖Qt图形界面搭建、OpenCV图像预处理、C模型加载与结果解析以及轻量级YOLOv5模型的推理流程适合希望打通嵌入式开发、界面设计与深度学习部署的中高级学习者。资源包共79个文件约32.86MB包含cpp与h源码、ui界面文件、so动态库、onnx模型、qrc资源脚本及png、ico图标素材并附带bmp测试图像与xml分类器文件目录结构清晰便于按模块查阅与二次开发。目前已有1357人学习下载。通过该工程读者可掌握模型格式转换、图像预处理、推理结果在Qt界面中的可视化呈现以及资源受限环境下的性能优化思路快速搭建可运行的边缘端目标检测应用。1. 树莓派上跑 YOLOv5-Lite为什么 C 比 Python 更值得折腾很多人第一次在树莓派上跑深度学习推理都是先用 Python 把模型跑通看到检测框弹出来那一刻挺兴奋然后一测帧率——三帧、五帧风扇狂转板子烫手。这时候才意识到Python 那层解释器和 GIL 在资源受限的 ARM 设备上开销比想象中大得多。于是就有了这条路线Qt 做界面、OpenCV 做图像处理、YOLOv5-Lite 做轻量检测、C 做胶水把所有东西串起来最终在树莓派上得到一个能交互、能实时出结果的完整应用。这个方案解决的核心问题是在算力有限的嵌入式设备上把「能跑」变成「能用」。适合谁适合已经会用 Python 调通模型、但被性能卡住的嵌入式开发者也适合做边缘计算终端、智能摄像头、小型机器人这类产品的工程师。YOLOv5-Lite 相比原版 YOLOv5在骨干网络和检测头上做了裁剪参数量和计算量都压下来不少配合 NCNN 或 OpenCV DNN 这类推理后端在树莓派 4B 上做到 10 FPS 以上是有可能的。整条链路用 C 写Qt 负责界面刷新和事件循环OpenCV 负责图像采集和预处理推理引擎负责前向计算各司其职。2. 环境搭建与推理后端选型NCNN 还是 OpenCV DNN2.1 树莓派系统准备与依赖安装拿到树莓派后第一件事是确认系统版本和架构。推荐用 64 位 Raspberry Pi OS因为 32 位系统在内存寻址和某些推理库的支持上会受限。用uname -m确认输出是aarch64如果是armv7l说明是 32 位建议重刷。接下来安装基础编译工具和库。Qt 在树莓派上可以用 apt 装也可以自己编译但自己编译 Qt 在树莓派上耗时很长一般直接用系统包sudo apt update sudo apt install -y build-essential cmake git sudo apt install -y qtbase5-dev qt5-qmake qtdeclarative5-dev sudo apt install -y libopencv-dev python3-opencv这里装的是 Qt5 和 OpenCV 的系统包。OpenCV 版本通常是 4.x具体小版本取决于系统源。装完后用pkg-config --modversion opencv4确认版本。提示树莓派上编译大型 C 项目时建议加-j4但不要超过 4否则内存容易爆。如果编译过程中卡死先检查是不是 swap 不够。2.2 NCNN 与 OpenCV DNN 的对比与选择推理后端有两个常见选择NCNN 和 OpenCV DNN。NCNN 是专门为移动端和嵌入式优化的推理框架对 ARM 有 NEON 指令集加速内存占用小速度快。OpenCV DNN 的好处是跟 OpenCV 生态无缝集成预处理和后处理都在同一个框架里代码量少但性能通常比 NCNN 差一截。我一般会这样选如果项目对帧率要求高比如要做实时跟踪优先 NCNN如果只是做低频检测比如几秒一帧OpenCV DNN 够用且开发快。下面以 NCNN 为主线因为这条路线更能体现 C 部署的价值。NCNN 的安装有两种方式直接下载预编译库或者从源码编译。树莓派上建议源码编译因为可以针对具体架构优化git clone https://github.com/Tencent/ncnn.git cd ncnn mkdir build cd build cmake -DCMAKE_BUILD_TYPERelease -DNCNN_VULKANOFF -DNCNN_BUILD_EXAMPLESOFF .. make -j4 sudo make installNCNN_VULKANOFF是因为树莓派的 GPU 对 Vulkan 支持有限开了反而可能出问题。NCNN_BUILD_EXAMPLESOFF是为了减少编译时间。编译完成后头文件在/usr/local/include/ncnn库文件在/usr/local/lib。2.3 模型转换从 PyTorch 到 NCNNYOLOv5-Lite 的权重通常是.pt格式需要先导出为 ONNX再转成 NCNN 的.param和.bin。这一步在 PC 上做不要在树莓派上做因为树莓派跑 PyTorch 导出很慢。导出 ONNX 的脚本大致如下import torch from models.experimental import attempt_load model attempt_load(yolov5-lite.pt, map_locationcpu) dummy_input torch.randn(1, 3, 320, 320) torch.onnx.export( model, dummy_input, yolov5-lite.onnx, opset_version11, input_names[images], output_names[output], dynamic_axes{images: {0: batch}, output: {0: batch}} )输入尺寸这里用 320x320是因为树莓派算力有限320 比 640 快很多精度损失在多数场景可接受。导出后用 onnx-simplifier 简化一下去掉冗余算子。然后转 NCNNonnx2ncnn yolov5-lite.onnx yolov5-lite.param yolov5-lite.bin转换后检查.param文件确认输入输出层名字和维度正确。有时候会出现某些算子不支持的情况需要手动改.param或者换 opset 版本重新导出。3. C 推理管线从图像采集到检测框输出3.1 图像预处理letterbox 与归一化YOLOv5-Lite 的输入要求是固定尺寸但实际摄像头采集的图像比例不一定匹配。直接 resize 会变形影响检测精度。标准做法是 letterbox保持长宽比缩放不足的部分用灰色填充。cv::Mat letterbox(const cv::Mat img, int target_size, float scale) { int w img.cols, h img.rows; scale std::min(target_size / (float)w, target_size / (float)h); int new_w int(w * scale), new_h int(h * scale); cv::Mat resized; cv::resize(img, resized, cv::Size(new_w, new_h)); int pad_w target_size - new_w, pad_h target_size - new_h; int top pad_h / 2, bottom pad_h - top; int left pad_w / 2, right pad_w - left; cv::Mat padded; cv::copyMakeBorder(resized, padded, top, bottom, left, right, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); return padded; }scale是缩放比例后处理时要把检测框坐标映射回原图必须用到这个值。填充值 114 是 YOLO 系列的惯例训练时用的就是这个灰度值推理时保持一致能减少分布偏移。预处理还包括归一化把像素值从 0-255 转到 0-1再减均值除标准差。YOLOv5 的均值和标准差都是 0.5所以等价于(pixel / 255 - 0.5) / 0.5简化后就是pixel / 127.5 - 1.0。3.2 NCNN 推理核心代码加载模型和推理的流程比较固定关键是输入输出的维度要对上#include ncnn/net.h ncnn::Net net; net.opt.use_vulkan_compute false; net.opt.num_threads 4; net.load_param(yolov5-lite.param); net.load_model(yolov5-lite.bin); ncnn::Mat in ncnn::Mat::from_pixels_resize( padded.data, ncnn::Mat::PIXEL_BGR2RGB, padded.cols, padded.rows, 320, 320); const float mean_vals[3] {0.f, 0.f, 0.f}; const float norm_vals[3] {1/255.f, 1/255.f, 1/255.f}; in.substract_mean_normalize(mean_vals, norm_vals); ncnn::Extractor ex net.create_extractor(); ex.input(images, in); ncnn::Mat out; ex.extract(output, out);num_threads设成 4 是因为树莓派 4B 是四核设多了反而有调度开销。use_vulkan_compute关掉前面说过原因。输入层名字images和输出层名字output要和导出 ONNX 时设的一致否则extract会失败。输出out的维度通常是[1, 25200, 85]或类似25200 是候选框数量85 是[x, y, w, h, obj_conf, cls_conf_0, ..., cls_conf_79]。具体维度取决于模型版本和输入尺寸用out.w、out.h、out.c打印确认。3.3 后处理NMS 与坐标映射后处理要做三件事过滤低置信度框、按类别做 NMS、把坐标映射回原图。struct Detection { cv::Rect box; float score; int class_id; }; std::vectorDetection postprocess(const ncnn::Mat out, float scale, int orig_w, int orig_h, float conf_thres, float nms_thres) { std::vectorDetection dets; const int num_anchors out.h; const int num_classes out.w - 5; for (int i 0; i num_anchors; i) { const float* row out.row(i); float obj_conf row[4]; if (obj_conf conf_thres) continue; int best_cls 0; float best_score 0; for (int c 0; c num_classes; c) { if (row[5 c] best_score) { best_score row[5 c]; best_cls c; } } float score obj_conf * best_score; if (score conf_thres) continue; float cx row[0], cy row[1], w row[2], h row[3]; float x1 (cx - w / 2) / scale; float y1 (cy - h / 2) / scale; float x2 (cx w / 2) / scale; float y2 (cy h / 2) / scale; x1 std::max(0.f, std::min(x1, (float)orig_w)); y1 std::max(0.f, std::min(y1, (float)orig_h)); x2 std::max(0.f, std::min(x2, (float)orig_w)); y2 std::max(0.f, std::min(y2, (float)orig_h)); dets.push_back({cv::Rect(x1, y1, x2 - x1, y2 - y1), score, best_cls}); } // NMS 按类别分组做 std::vectorDetection result; std::vectorint indices; std::vectorcv::Rect boxes; std::vectorfloat scores; for (auto d : dets) { boxes.push_back(d.box); scores.push_back(d.score); } cv::dnn::NMSBoxes(boxes, scores, conf_thres, nms_thres, indices); for (int idx : indices) result.push_back(dets[idx]); return result; }conf_thres一般设 0.25nms_thres设 0.45。这两个值不是固定的如果发现漏检多就降 conf_thres如果发现同一个物体出多个框就降 nms_thres。坐标映射时除以scale是因为预处理时缩放了要还原回去。裁剪到原图边界是防止浮点误差导致坐标越界。4. Qt 界面与推理线程别让界面卡住推理4.1 用 QThread 把推理放到独立线程Qt 的主线程负责界面刷新和事件处理如果把推理也放在主线程界面会直接卡死。正确做法是把推理逻辑放到 QThread 里通过信号槽把结果传回主线程更新界面。class InferenceWorker : public QThread { Q_OBJECT public: InferenceWorker(QObject* parent nullptr) : QThread(parent) {} void stop() { running_ false; } signals: void frameReady(const QImage img); void statsReady(float fps, int det_count); protected: void run() override { cv::VideoCapture cap(0); cap.set(cv::CAP_PROP_FRAME_WIDTH, 640); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480); running_ true; while (running_) { cv::Mat frame; cap frame; if (frame.empty()) continue; auto dets infer(frame); for (auto d : dets) { cv::rectangle(frame, d.box, cv::Scalar(0, 255, 0), 2); } cv::cvtColor(frame, frame, cv::COLOR_BGR2RGB); QImage qimg(frame.data, frame.cols, frame.rows, frame.step, QImage::Format_RGB888); emit frameReady(qimg.copy()); } } private: bool running_ false; // infer 函数封装前面的预处理、推理、后处理 };QImage构造时用的是frame.data指针但frame在循环下一轮会被覆盖所以必须.copy()一份再发信号否则界面显示的是脏数据。这个坑我踩过现象是界面偶尔花屏查了半天才发现是内存复用问题。4.2 信号槽连接与界面刷新主线程里创建 worker连接信号槽然后启动线程InferenceWorker* worker new InferenceWorker(this); connect(worker, InferenceWorker::frameReady, this, [](const QImage img) { ui-label-setPixmap(QPixmap::fromImage(img).scaled( ui-label-size(), Qt::KeepAspectRatio, Qt::FastTransformation)); }); connect(worker, InferenceWorker::statsReady, this, [](float fps, int n) { ui-statusLabel-setText(QString(FPS: %1 Objects: %2).arg(fps).arg(n)); }); worker-start();Qt::FastTransformation比Qt::SmoothTransformation快在树莓派上界面缩放用快速模式更合适否则缩放本身就成了瓶颈。信号槽默认是队列连接跨线程安全但要注意不要在槽函数里做耗时操作否则主线程还是会卡。4.3 帧率统计与性能监控帧率统计不能简单用1 / 单帧耗时因为单帧耗时波动大。我一般用滑动窗口统计最近 30 帧的平均耗时QElapsedTimer timer; QQueueqint64 frame_times; timer.start(); // 每帧结束后 frame_times.enqueue(timer.restart()); if (frame_times.size() 30) frame_times.dequeue(); qint64 sum 0; for (auto t : frame_times) sum t; float fps 1000.0f * frame_times.size() / sum; emit statsReady(fps, dets.size());这样得到的帧率比较平滑不会因为某一帧的抖动导致显示跳变。如果发现帧率突然掉到很低先检查是不是摄像头采集本身变慢了而不是推理变慢了。5. 避坑与排查树莓派部署最容易翻车的五个点5.1 模型加载失败或输出全零现象net.load_param返回非零或者推理输出全是零。原因通常是.param文件里的层名和代码里ex.input/ex.extract用的名字不一致或者 ONNX 导出时 opset 版本和 onnx2ncnn 不兼容。解决打开.param文件第一行是层数后面每行是层定义找到输入层和输出层的名字跟代码对齐。如果输出全零用 Netron 打开 ONNX 确认输出节点名字重新导出。5.2 帧率远低于预期现象Python 下能跑 5 FPSC 下只有 2 FPS。原因可能是编译时没开优化或者 NCNN 没启用 NEON。解决CMake 里加-O3 -mfpuneon确认 NCNN 编译时CMAKE_BUILD_TYPERelease。另外检查是不是在 debug 模式下跑debug 模式下 NCNN 会做很多额外检查速度差好几倍。5.3 界面显示花屏或颜色错乱现象Qt 界面上的图像偶尔花屏或者颜色偏蓝。原因花屏是 QImage 没有 copy颜色错乱是 BGR 和 RGB 没转换。解决QImage构造后立即.copy()OpenCV 的 BGR 图像转 Qt 之前先cvtColor成 RGB。这两个问题经常一起出现排查时先确认颜色转换再确认内存拷贝。5.4 长时间运行后内存持续增长现象跑几个小时后内存占用越来越高最终被系统杀掉。原因每帧创建ncnn::Mat和cv::Mat没有及时释放或者信号槽里累积了未处理的事件。解决把ncnn::Net和ncnn::Extractor在循环外创建循环内复用cv::Mat用局部变量依赖 RAII 释放信号槽用Qt::DirectConnection要小心跨线程还是用队列连接但确保槽函数执行够快不积压事件。5.5 摄像头采集延迟越来越大现象界面显示的图像比实际场景慢好几秒而且延迟随时间增加。原因推理速度跟不上采集速度cap frame一直在取新帧但处理的是旧帧缓冲区越积越多。解决在循环里连续取两帧丢弃第一帧用第二帧这样保证拿到的是最新帧。或者设置cap.set(cv::CAP_PROP_BUFFERSIZE, 1)但有些摄像头驱动不支持这个参数。6. 进阶技巧量化、多线程与模型裁剪的取舍模型量化是提升树莓派推理速度最直接的手段。NCNN 支持 int8 量化能把模型体积压到原来的四分之一速度提升也很明显。但量化不是无脑开需要校准数据集而且量化后精度会掉掉多少取决于模型和任务。我一般会先跑 fp16如果速度够就不做 int8如果 fp16 还不够再考虑 int8同时准备一个精度验证集量化后跑一遍确认 mAP 掉不超过 3 个点。fp16 转换在 NCNN 里比较简单用ncnnoptimize工具ncnnoptimize yolov5-lite.param yolov5-lite.bin \ yolov5-lite-opt.param yolov5-lite-opt.bin 65536最后一个参数 65536 表示 fp16 存储0 表示 fp32。fp16 在 ARM 上不一定有硬件加速但内存带宽占用减半对树莓派这种内存带宽有限的设备还是有帮助的。多线程方面NCNN 的num_threads不是越大越好。树莓派 4B 四核设 4 通常最优但如果你同时还在跑其他线程比如 Qt 界面刷新、摄像头采集设 3 可能更稳。我试过设 2 和设 4帧率差大概 15%但设 4 时界面响应会稍微迟钝一点。这个需要根据实际负载调。模型裁剪是另一个方向。YOLOv5-Lite 本身已经是裁剪过的但如果你的场景类别很少比如只检测人和车可以把输出类别数从 80 降到 2检测头部分的计算量会大幅下降。这需要重新训练或者至少微调不是纯部署能解决的。如果不想重训可以用 NCNN 的ncnnoptimize做算子融合把 ConvBNReLU 合并成一个算子也能省一些时间。最后说一个我自己的习惯每次改完参数或者换模型先跑一个固定的测试视频记录帧率和检测结果跟上一版对比。不要凭感觉说「好像快了」数据不会骗人。树莓派上部署深度学习调优的空间其实比想象中大但前提是你知道每一步在做什么而不是盲目试参数。希望帮到你。本文还有配套的精品资源点击获取