一、项目概述
在进迭时空 SpacemiT K1 RISC-V 开发板上,实现了一个完整的Qt5 GUI + 摄像头实时采集 + YOLOv8 目标检测应用,并最终将推理后端从 OpenCV DNN CPU 推理切换到ONNX Runtime + SpacemiT NPU 硬件加速,推理性能获得显著提升。
技术栈
| 组件 | 技术选型 |
|---|---|
| 芯片 | SpacemiT K1(8 核 RISC-V,融合 AI 架构) |
| 操作系统 | Buildroot Linux(OpenHarmony 5.0 兼容) |
| GUI 框架 | Qt5 Widgets |
| 摄像头采集 | GStreamer + SpacemiT CSI 驱动(NV12 输出) |
| 图像处理 | OpenCV 4.8 |
| AI 推理 | ONNX Runtime 1.18.1 + SpacemiT EP(NPU 加速) |
| 检测模型 | YOLOv8n(80 类 COCO,640×640 输入) |
| 交叉编译 | Docker (Ubuntu 24.04) + Buildroot Toolchain |
最终效果
- 摄像头 30fps 采集,每 30 帧执行一次 YOLOv8 推理
- SpacemiT NPU 硬件加速推理
- Qt5 Wayland 界面实时显示检测框和类别标签
- 采集线程(CPU 0-3)与推理线程(CPU 6-7)完全隔离,系统稳定
二、开发环境搭建
2.1 硬件环境
- 进迭时空 K1 开发板(RISC-V 8 核,2 TOPS 融合 AI 算力)
- IMX415 CSI 摄像头
- HDMI/DSI 显示屏
2.2 主机环境
- Ubuntu 22.04 (x86_64)
- Docker(用于交叉编译,因为 Buildroot Toolchain 需要 GLIBC 2.38+)
- Buildroot SDK 2.2
2.3 关键路径
# Buildroot SDK ~/buildroot-sdk-2.2/output/mlk_k1_v2_defconfig/ # 交叉编译器 .../host/bin/riscv64-unknown-linux-gnu-g++ # sysroot(Qt5, OpenCV, ONNX Runtime 均在此) .../host/riscv64-buildroot-linux-gnu/sysroot/ # ONNX Runtime .../sysroot/usr/lib/libonnxruntime.so.1.18.1 .../sysroot/usr/include/onnxruntime_cxx_api.h # SpacemiT NPU EP .../sysroot/usr/lib/libspacemit_ep.so.1.2.3三、原始代码分析
项目最初的实现使用OpenCV DNN作为推理后端,架构如下:
SpacemiT CSI Camera (NV12) → GStreamer spacemitsrc → NV12 → BGR (OpenCV cvtColor) → NV12 → BGR (OpenCV cvtColor) → cv::dnn::readNetFromONNX("yolov8n.onnx") → cv::dnn::blobFromImage → net.forward() → 后处理 + NMS → 画检测框 → QImage → Qt5 QLabel原始代码关键片段
// cameraworker.cpp - 原始推理逻辑 cv::dnn::Net net = cv::dnn::readNetFromONNX(modelPath); // 每 90 帧推理一次 if (frameCount % 90 == 0 && !inferRunning) { inferRunning = true; cv::Mat bgrCopy = bgr.clone(); std::thread([this, &net, bgrCopy, pInfer]() { cv::Mat blob = cv::dnn::blobFromImage(letterbox, 1.0/255.0, cv::Size(640, 640), cv::Scalar(), true, false); net.setInput(blob); cv::Mat out = net.forward(); // ... 后处理 }).detach(); }四、发现的问题
4.1 六个稳定性问题
| # | 问题 | 原因 | 影响 |
|---|---|---|---|
| 1 | QObject 跨线程创建子对象 | 信号槽默认 AutoConnection,跨线程时变成 DirectConnection | GUI 崩溃 |
| 2 | 检测结果数据竞争 | 推理线程写、GUI 线程读,无互斥保护 | 随机崩溃 |
| 3 | ISP 资源冲突 SIGSEGV | OpenCV DNN 与 GStreamer ISP 库冲突 | 段错误 |
| 4 | 推理异常未捕获 | ONNX 模型格式不匹配或内存不足时直接 crash | 进程退出 |
| 5 | 模型路径无校验 | 文件不存在时直接崩溃 | 启动崩溃 |
| 6 | nice 导致推理饿死 | nice(5) 降低优先级,推理线程长时间无输出 | 功能失效 |
4.2 性能问题
- 推理只绑定 CPU 6-7 两个核,且
nice(5)主动让出 CPU - 每 90 帧才推理一次(约 3 秒),目标移动快时框严重滞后
std::thread(...).detach()存在栈变量引用悬空风险- SpacemiT NPU 完全未使用,2 TOPS 算力浪费
五、解决方案
5.1 架构重设计
┌─────────────────────────────────────────────────┐ │ Widget (Qt5 GUI, 主线程) │ │ connect → 全部 QueuedConnection │ ├─────────────────────────────────────────────────┤ │ CameraWorker (采集线程, CPU 0-3) │ │ spacemitsrc → NV12 → BGR → emit frameReady │ │ → 每30帧发送给推理线程 │ ├─────────────────────────────────────────────────┤ │ inferenceLoop (推理线程, CPU 6-7) │ │ condition_variable 接收帧 │ │ YoloDetector::detect() → ONNX Runtime + NPU │ │ std::lock_guard<std::mutex> 写结果 │ └─────────────────────────────────────────────────┘5.2 NPU 加速:找到正确的注册方式
这是整个项目最具挑战的部分。
第一步:发现 EP 库
# 开发板上 ls /usr/lib/libspacemit_ep.so # libspacemit_ep.so → libspacemit_ep.so.1 → libspacemit_ep.so.1.2.3第二步:尝试 ORT 标准 API 失败
// 方式 1:AppendExecutionProvider(string) — 失败 opts.AppendExecutionProvider("SpaceMITExecutionProvider", {}); // 错误:Unknown provider name. Currently supported values are // 'OPENVINO', 'SNPE', 'XNNPACK', 'QNN', 'WEBNN' and 'AZURE'第三步:从 .so 导出符号中找到真相
readelf -Ws libspacemit_ep.so | grep "FUNC.*GLOBAL" | grep -v "UND"SpaceMITSharedProviderInit CreateSpaceMITSessionWrapper GetSpaceMITSharedProviderFactory OrtSessionOptionsSpaceMITEnvInit ← 关键!第四步:查看官方头文件确认 API
cat spacemit_ort_env_c_api.hORT_EXPORT OrtStatus* ORT_API_CALL OrtSessionOptionsSpaceMITEnvInit( OrtSessionOptions* options, _In_reads_(num_keys) const char* const* provider_options_keys, _In_reads_(num_keys) const char* const* provider_options_values, size_t num_keys);第五步:正确的 NPU 注册代码
bool YoloDetector::trySpacemiTEP(Ort::SessionOptions& opts) { void* handle = dlopen("libspacemit_ep.so", RTLD_NOW | RTLD_LOCAL); if (!handle) return false; // 查找官方 C API using RegFn = OrtStatus*(ORT_API_CALL*)( OrtSessionOptions*, const char* const*, const char* const*, size_t); auto fn = reinterpret_cast<RegFn>( dlsym(handle, "OrtSessionOptionsSpaceMITEnvInit")); if (!fn) { dlclose(handle); return false; } // 注册 NPU EP OrtStatus* status = fn(opts, nullptr, nullptr, 0); if (status == nullptr) { std::cout << "[INFO] NPU EP registered" << std::endl; return true; // 不 dlclose,运行时需要 } // 错误处理 const OrtApi* api = OrtGetApiBase()->GetApi(ORT_API_VERSION); api->ReleaseStatus(status); dlclose(handle); return false; }5.3 六个崩溃的修复方案
崩溃 1:信号槽线程类型
// 修复前:默认 AutoConnection connect(thread_, &QThread::started, worker_, &CameraWorker::startCamera); // 修复后:显式 QueuedConnection connect(thread_, &QThread::started, worker_, &CameraWorker::startCamera, Qt::QueuedConnection);崩溃 2:数据竞争
// 修复后:所有读写都加锁 std::mutex detMtx_; // 检测结果互斥锁 std::mutex inferMtx_; // 推理帧传递互斥锁 // 写 { std::lock_guard<std::mutex> lk(detMtx_); lastDets_ = std::move(boxes); } // 读 { std::lock_guard<std::mutex> lk(detMtx_); for (const auto& d : lastDets_) { /* 画框 */ } }崩溃 3:ISP 资源隔离
// 采集线程绑定 CPU 0-3 cpu_set_t cpuset; for (int i = 0; i < 4; i++) CPU_SET(i, &cpuset); sched_setaffinity(0, sizeof(cpuset), &cpuset); // 推理线程绑定 CPU 6-7 cpu_set_t cs; CPU_SET(6, &cs); CPU_SET(7, &cs); sched_setaffinity(0, sizeof(cs), &cs); cv::setNumThreads(1);崩溃 4:推理异常捕获
try { auto dets = detector_.detect(frame); // ... } catch (const Ort::Exception& e) { std::cerr << "[ERROR] ORT: " << e.what() << std::endl; } catch (const std::exception& e) { std::cerr << "[ERROR] Inference: " << e.what() << std::endl; }崩溃 5:模型文件校验
std::ifstream f(modelPath); if (!f.good()) { std::cerr << "[ERROR] Model not found: " << modelPath << std::endl; return false; }崩溃 6:推理线程饥饿
// 修复前:nice(5) + detach nice(5); std::thread([...](){ ... }).detach(); // 修复后:独立线程 + 条件变量 + 超时 std::thread inferThread_; std::condition_variable inferCv_; inferCv_.wait_for(lk, std::chrono::seconds(2), [this]{ return inferReady_ || !running_; });六、代码
6.1 项目结构
yolo-qt5-cam/ ├── CMakeLists.txt # 构建配置 ├── Dockerfile # Docker 交叉编译环境 ├── toolchain-k1.cmake # RISC-V 工具链 ├── main.cpp # 入口 ├── widget.h / widget.cpp # Qt5 GUI ├── cameraworker.h / .cpp # 采集 + 推理调度 ├── yolodetector.h / .cpp # ONNX Runtime + SpacemiT NPU ├── models/ │ └── yolov8n.onnx └── build-rv/ └── YoloQt5Cam # RISC-V 二进制7.2 编译命令
docker run --rm \
-v /home/uisrc/buildroot-sdk-2.2:/home/uisrc/buildroot-sdk-2.2 \
-v /home/uisrc/yolo-qt5-cam:/yolo-qt5-cam \
yolo-qt5-cam:latest \
export PATH=/home/uisrc/buildroot-sdk-2.2/output/mlk_k1_v2_defconfig/host/bin:\$PATH && \
cd /yolo-qt5-cam/build-rv && \
cmake .. -DCMAKE_TOOLCHAIN_FILE=../toolchain-k1.cmake -DCMAKE_BUILD_TYPE=Release && \
make -j\$(nproc)
7.3 部署
scp /home/uisrc/yolo-qt5-cam/build-rv/YoloQt5Cam root@192.168.137.2:/yolo-qt5-cam/build/
八、开发板运行
chmod +x /yolo-qt5-cam/build/YoloQt5Cam
export LD_LIBRARY_PATH=/usr/lib64:/lib
export QT_QPA_PLATFORM=wayland
export XDG_RUNTIME_DIR=/root
export WAYLAND_DISPLAY=wayland-1
/yolo-qt5-cam/build/YoloQt5Cm 2>&1 | \
grep -v "cam_wrn\|cam_not\|cam_inf\|cam_err\|ccic\|goodix\|usb 3-1"