从零开始开发一个 RK3588S 边缘端 AI 检测系统(采集→NPU推理→硬编码→RTMP推流→MQTT上报)

0. 项目简介

AlertGateway 是一个在 RK3588S 开发板上运行的边缘端 C++ 应用,目标是验证一条完整的嵌入式 AI 业务链路:摄像头采集 → NPU 推理(YOLOv8s)→ 检测结果画框 → 硬件编码 → RTMP 推流 → MQTT 上报检测结果。场景是摄像头俯拍桌面,识别手机、杯子、键盘等桌面常见物品。

28

最终效果:单帧端到端推理总耗时约 35ms(NPU 推理本身 ~30ms,已通过官方 demo 交叉验证为该型号 NPU 的硬件极限),编码推流稳定运行在摄像头实际帧率(14.6fps),RTMP 端到端延迟 < 1s,支持断线自动重连。

代码地址:github.com/johnjiamzhong-project/AlertGateway

本文按开发的时间顺序,记录从环境搭建到性能调优的完整过程,重点是过程中遇到的 9 个典型问题(完整记录见仓库 BUGS.md)的现象、原因排查和修复方法。

0.1 硬件与软件环境

项目 配置
开发板 Firefly ROC-RK3588S-PC,8GB 内存
SoC Rockchip RK3588S(4×A76 + 4×A55,6 TOPS NPU,独立 RGA 2D 加速单元)
板载系统 Ubuntu 22.04 aarch64
摄像头 罗技 C310(USB UVC),/dev/video20,YUYV422,640×480
交叉编译环境 WSL2 Ubuntu 22.04 x86_64
编译器 aarch64-linux-gnu-g++ 11.4.0
构建系统 CMake
推理框架 RKNN Lite2 C API,模型 YOLOv8s(INT8 量化)
硬件编解码 Rockchip MPP(h264_rkmpp
推流协议 RTMP
消息上报 Paho MQTT C++
配置/JSON nlohmann/json
线程通信 有界阻塞队列 + 条件变量

0.2 摄像头参数确认

接入摄像头后第一步是确认它的真实能力,而不是假设它支持期望的参数:

v4l2-ctl -d /dev/video20 --list-formats-ext

输出显示 640×480 YUYV 格式声明支持 30/25/20/15/10/5fps 六档,USB 3.0 接口带宽(YUYV 30fps 仅需 17.6MB/s)远未跑满。但接入完整推流链路后实测吞吐稳定在 14.6fps,与带宽、USB 接口版本均不匹配,瓶颈推测在 sensor 曝光/读出耗时或 ISP 处理延迟,未继续深挖。这个"声明帧率"与"实测帧率"的落差直接影响了第 5 节 RTMP 延迟问题的判断。

结论:驱动声明支持的帧率档位,不等于实际链路能稳定吐出的帧率,接入硬件后必须实测验证。


1. MPP 硬解硬编打通

在接入 NPU 推理之前,先单独验证硬件编码 + RTMP 推流链路,避免后续调试时混淆是推理问题还是编解码问题。

RK3588S 的硬件编解码走 Rockchip 自有的 MPP(Media Process Platform)框架,标准 FFmpeg 需要编译时开启 --enable-rkmpp 才能使用 h264_rkmpp codec。板载系统自带的 FFmpeg(4.4.2,官方 apt 包)未开启该选项,需要额外部署 ffmpeg-rockchip(实测版本 6.1)。

官方 mpi_dec_test 验证结果:192.79fps(800 帧,4149ms),证明硬件性能本身远超应用需求,瓶颈在软件层。

1.1 问题:FFmpeg 头文件版本与运行库不匹配导致段错误

现象

Segmentation fault (core dumped)

gdb 定位到 avformat_new_stream() 内部崩溃。

原因

sysroot 中同时存在两套 FFmpeg:

  • 系统 FFmpeg 4.4.2 头文件:/usr/include/aarch64-linux-gnu

  • ffmpeg-rockchip 6.1 运行库:/opt/ffmpeg-rockchip/lib

    CMakeLists.txt 只添加了系统头文件路径,但实际链接的是 6.1 版本的 .so。两个版本的 AVFormatContextAVStream 结构体内存布局不同,导致字段偏移错误,编译期无任何报错,运行时访问非法内存。

    修复

    将 ffmpeg-rockchip 头文件路径置于系统头文件路径之前:

include_directories(${SYSROOT}/opt/ffmpeg-rockchip/include)
include_directories(${SYSROOT}/usr/include/aarch64-linux-gnu)

结论:链接哪个版本的 .so,就必须 include 对应版本的头文件;多套同名库共存时,include_directories 的顺序决定生效版本,顺序错误时编译不会报错,只会在运行时崩溃。


2. RKNN 推理部署

2.1 模型选型

原计划使用 YOLOv5s,实际部署改用 YOLOv8s(精度更优,RKNN 部署相关资料更完整)。模型转换流程:PyTorch → ONNX → RKNN(rknn-toolkit2,仅支持 x86_64 Linux,本文在 WSL2 环境完成转换)。

2.2 问题:INT8 量化后检测框始终为空

现象

接入 RGA 预处理加速并修复一处反量化错误后,程序运行无报错,但输出视频中没有任何检测框,调低 conf_threshold 同样无效。

原因排查

  1. yolov8s.onnx 导出时,检测框坐标(4 通道,数值范围 0-640)与分类置信度(80 通道,数值范围 0-1)被合并为单个 84 通道输出张量,量化时共享同一组 scale/zero-point。该 scale(约 2.6)按坐标数值范围校准,分类概率所在的 0-1 区间在此 scale 下不足 1 个 INT8 量化档位,反量化后全部归零,分类信息在量化阶段已丢失,运行时无法补救。

  2. 附带问题:YOLOv8 的 ONNX 图分类分支自带 Sigmoid 节点,C++ 后处理代码又额外做了一次 sigmoid,属重复计算。

    修复

    使用 rknn.load_onnx()outputs 参数,在 ONNX 图最后一次 concat 之前,将 box 与 cls 分支拆分为两个独立输出分别量化:

rknn.load_onnx(
    model="yolov8s.onnx",
    outputs=['/model.22/Mul_2_output_0', '/model.22/Sigmoid_output_0']
)

C++ 侧改用 rknn_outputs_get(want_float=1) 直接获取两个输出的浮点值,移除手动反量化和重复 sigmoid,后处理函数同时接收 box/cls 两个缓冲区。

结论:YOLO 系列模型导出 INT8 时,box 回归与分类两个分支数值量级差异巨大,必须拆分为独立输出张量分别量化校准;合并为单一张量共享 scale 会摧毁其中一个分支的精度。排查反量化问题时应先打印原始浮点值确认数据是否存在,而非仅调整阈值。

2.3 问题:推理速度仅为行业基准的 1/4

现象

实测推理总耗时约 150ms/帧(6.7 FPS),YOLOv8s 在 RK3588S 上的行业基准为 27-32 FPS。

排查

分阶段打点:

cpu(YUYV→RGB + resize):  8ms
rknn_inputs_set:           37ms
rknn_run(NPU 计算):      75ms
rknn_outputs_get:          37ms
total:                    150ms

rknn_run 75ms 高于参考值(30-40ms)约一倍,检查模型量化类型:

strings ~/AlertGateway/model/yolov8s.rknn | grep -E 'dtype|quant|int8|float'
# 输出: "dtype": "float16",量化参数段为空

确认模型为 FP16,未做 INT8 量化,NPU 以 16 位浮点运算,性能为 INT8 的约一半。另测试三核 NPU mask(rknn_set_core_mask(RKNN_NPU_CORE_0_1_2)),耗时无变化——该模型导出时未编译多核支持,driver 接受 mask 但实际仍单核执行。

修复

  1. 采集 150 张真实场景图作为 INT8 量化校准数据集(脚本:tools/collect_calibration.py

  2. 在 WSL x86_64 环境用 rknn-toolkit2 重新量化转换(脚本:tools/convert_int8.py

    转换过程中的两个环境问题:

  • onnx 1.22.0 移除了 onnx.mapping 接口,rknn-toolkit2 依赖该接口,降级至 onnx==1.13.1 解决

  • rknn.build(dataset=...) 仅接受文件路径字符串,不接受 numpy 数组列表,需将图片路径写入 dataset.txt 后传路径

    效果

阶段 FP16(优化前) INT8(优化后)
rknn_run 75ms 38ms
inputs_set 37ms 0.4ms
outputs_get 37ms 1-3ms
total ~150ms ~48ms

推理速度从 6.7 FPS 提升至约 20 FPS,超过摄像头实际帧率(14.6fps)上限,整体提速 3 倍。

后续测试了 SRAM 缓存、异步模式、Zero-Copy I/O 等优化手段:SRAM 缓存无明显效果(模型中间张量过大,2MB SRAM 仅覆盖部分层);异步模式(RKNN_FLAG_ASYNC_MASK)使等待被推迟到输出读取时刻,总耗时反而增加;Zero-Copy I/O 减少了数据搬运开销但 rknn_run 本身耗时不变。结论:rknn_run 约 40ms 已是该 NPU 跑此模型的硬件极限(已用官方 demo 交叉验证),进一步提速需从模型层面入手(换更小模型、降低输入分辨率、重新导出开启多核编译)。


3. Pipeline 解耦重构

3.1 问题:串行架构导致帧率被推理速度拖死

现象

MPP 和 RKNN 单独验证均正常,但完整链路运行后画面严重卡顿,实测帧率约 5fps,远低于摄像头能力(14.6fps)。

原因

原始架构为串行结构:

CaptureThread → [queue] → InferThread → [queue] → EncodeThread → StreamThread

InferThread 每帧执行 YUYV→RGB 全图转换 + NPU 推理,当时单帧耗时约 200ms(~5fps);EncodeThread 阻塞等待 InferThread 输出,整条链路被推理速度限制,摄像头采集能力被浪费。

修复:解耦为并行双路架构

CaptureThread ──→ [enc_queue=1]   → EncodeThread → StreamThread
              └──→ [infer_queue=2] → InferThread  → SharedDetections
  • enc_queue:容量 1,阻塞推送,不丢帧,保证编码推流链路数据完整
  • infer_queue:容量 2,非阻塞推送,推理线程忙时直接丢帧,每次取帧排空队列,始终推理最新帧
  • 新增 SharedDetections 共享结构(mutex 保护),InferThread 写入检测结果,EncodeThread 无阻塞读取快照画框:
struct SharedDetections {
    mutable std::mutex mutex;
    std::vector<Detection> detections;
    void set(std::vector<Detection> dets) { ... }
    std::vector<Detection> get() const { ... }
};

同时将 EncodeThread 内 YUYV→RGB→NV12 两次色彩转换合并为一次直转:

void yuyv_to_nv12(const uint8_t* yuyv, uint8_t* nv12, int w, int h) {
    uint8_t* y_plane  = nv12;
    uint8_t* uv_plane = nv12 + w * h;
    // Y plane:逐字节提取 Y
    // UV plane:仅偶数行提取 U/V(2× 垂直降采样)
}

效果

解耦后 EncodeThread 帧率恢复至摄像头极限 14.6fps,推理速度不再影响编码推流帧率。

结论:多线程 pipeline 中,速度不一致的处理环节不应通过阻塞队列直接串联,否则整条链路速度等于最慢环节速度。允许丢帧的环节(推理)使用非阻塞队列,不允许丢帧的环节(编码推流)使用独立的阻塞队列保护。


4. RGA 硬件加速预处理

4.1 背景

Pipeline 解耦后单帧总耗时约 78-82ms,NPU 推理本身(~40ms)已确认为硬件极限,但 CPU 手写的预处理(yuyv_to_rgb + resize_rgb)耗时 13-14ms,总耗时已逼近摄像头出帧间隔(~68ms / 14.6fps),是当时需要跳帧推理(infer_every_n_frames)的直接原因。

4.2 方案

RK3588S 集成独立 RGA(2D 图形加速器)硬件单元,原生支持 RK_FORMAT_YUYV_422 格式,可一次硬件调用完成"YUYV→RGB888 + 缩放到模型输入尺寸"。

实现细节:

  • 使用新版 im2d API(improcess() / wrapbuffer_virtualaddr()),非旧版 RgaApi.h C 接口
  • 普通堆内存(std::vector<uint8_t>)即可,无需 DMA-BUF/ION
  • API 无状态,无需显式 init/deinit
  • 保留原 CPU 实现作为 RGA 调用失败时的 fallback
  • 交叉编译链接库直接使用板子上的真实文件(/usr/include/rga/*.hlibrga.so.2.1.0),避免重复 ABI 不匹配问题(参见第 5.1 节)

4.3 效果

预处理耗时由 13-14ms 降至 1ms 级。叠加 Zero-copy 输入(直接写入 NPU DMA 输入缓冲区,省去数据搬运),单帧 NPU 推理稳定在 ~30ms,端到端总耗时约 35ms。


5. 其他典型问题记录

5.1 paho-mqtt-cpp ABI 不匹配

现象

undefined symbol: _ZN4mqtt15connect_options17set_clean_sessionEb

原因

交叉编译链接的是本地新版 paho-mqtt-cpp(源码 build),板子上安装的是 1.2.0。新版头文件引入了旧版不存在的符号(如 set_clean_session 由 inline 改为非 inline)。

修复

  1. 从源码仓库 checkout 到与板子一致的 v1.2.0 版本头文件
  2. 将板子上的 .so 文件 scp 回来作为链接 stub:
scp firefly@<board-ip>:/usr/lib/aarch64-linux-gnu/libpaho-mqttpp3.so.1 third_party/paho/
scp firefly@<board-ip>:/usr/lib/aarch64-linux-gnu/libpaho-mqtt3as.so.1  third_party/paho/

结论:交叉编译时头文件版本必须与板子运行库版本完全一致,最安全的做法是直接从板子拷贝 .so 作为链接目标,再从对应 git tag 提取匹配头文件。本项目中 FFmpeg(1.1节)、RGA(4.2节)、paho-mqtt-cpp 三次独立踩到同一类问题,应作为交叉编译项目的标准检查项。

5.2 画框越界写入导致堆损坏

现象

double free or corruption (!prev)
Aborted (core dumped)

原因

draw_hline 函数仅检查 y < 0,未检查 y >= h

// 修复前
static void draw_hline(uint8_t* rgb, int w, int x0, int x1, int y, ...) {
    if (y < 0) return;
    ...
}

YOLOv8 输出的检测框坐标经坐标变换后,y2 可能恰好等于画面高度(480),写入位置越过缓冲区末尾,破坏相邻堆块元数据,导致后续 free() 触发 abort。

修复

// 修复后
static void draw_hline(uint8_t* rgb, int w, int h, int x0, int x1, int y, ...) {
    if (y < 0 || y >= h) return;
}

结论:图像缓冲区像素操作必须同时检查上下界;目标检测框坐标经缩放裁剪后容易出现边界值(恰好等于宽/高),需特别防范。

5.3 SSH 间歇性超时,ping 正常

现象

SSH 连接持续超时,但 ping 和端口探测均正常。

排查

最初怀疑 RTMP 推流(2Mbps)占满带宽,但验证发现程序未运行时 SSH 同样超时,排除该猜测。检查板子网络接口状态:

eth0:  NO-CARRIER, DOWN
wlan0: DORMANT, DOWN
p2p0:  UP

板子实际通过 USB WiFi 网卡的 P2P 虚拟接口联网(经 USB Hub),链路本身延迟高、丢包率高,且与 RTMP 推流、NPU 推理共用同一信道。ping 因 ICMP 包小、内核优先处理而正常,掩盖了真实链路质量问题。

修复

接入板子的千兆有线口,使管理通道(SSH)与数据通道(RTMP)走不同物理链路。

结论:ping 通但应用层连接不稳定时,优先排查实际使用的网络接口和链路类型,而非默认认定服务异常。嵌入式设备应优先使用有线网络,USB WiFi 经 Hub 的链路质量不可靠。

5.4 RTMP 断线后不自动重连

现象

SRS 服务器重启或网络抖动后,编码帧率从 14.6fps 跌至约 5fps,需手动重启程序才能恢复推流。

原因(三层叠加)

  1. 断线检测失效:原代码仅检查 av_write_frame 返回值,未检查 avio_flush 之后的错误状态,而 TCP 层的 EPIPE/ECONNRESET 只在 flush 时才会被捕获:
// 修复前
int ret = av_write_frame(fmt_ctx_, pkt);
if (ret >= 0) avio_flush(fmt_ctx_->pb);
return ret >= 0;

// 修复后
int ret = av_write_frame(fmt_ctx_, pkt);
if (ret < 0) return false;
avio_flush(fmt_ctx_->pb);
return fmt_ctx_->pb->error >= 0;
  1. 重连后画面无法恢复:MPP 编码器仅在启动时的第一个关键帧中携带 SPS/PPS,原重连逻辑会清空缓存的 SPS/PPS,重连后等不到新的关键帧,RTMP header 永远无法写出:
void StreamThread::reconnect_loop() {
    close_rtmp();  // 不清空 sps_/pps_
    while (running_) {
        if (open_rtmp()) {
            if (!sps_.empty() && !pps_.empty())
                write_extradata(sps_.data(), sps_.size(), pps_.data(), pps_.size());
            return;
        }
        // 重试期间持续排空 in_queue_,防止背压
    }
}
  1. 重连失败后线程退出:原逻辑重连一次失败即退出线程,无人消费队列,背压沿 EncodeThread → CaptureThread 传播,帧率跌至 5fps。修复为无限重试(间隔 3 秒),重试期间持续排空队列。

    结论:长连接服务(RTMP/MQTT/WebSocket)必须实现断线重连;重连逻辑需考虑重试期间下游队列的消费问题,否则会引发背压导致上游全部受影响;依赖首帧携带的元数据(如 SPS/PPS)在重连场景下必须缓存复用,不能依赖重新获取。

5.5 RTMP 延迟从 15 秒优化至 1 秒以内

现象

Pipeline 解耦后画面流畅,但 RTMP 延迟达到 15 秒。

排查

fps 配置 PTS 行为 现象
fps=30(错误值) PTS 推进速度 2 倍于实际帧率 延迟约 1s,画面卡顿丢帧
fps=15(正确值) PTS 节奏正常 流畅,但延迟 15s(SRS gop_cache 默认开启+较大队列+播放器默认缓冲叠加)

修复

  1. SRS 服务端配置:
min_latency  on;
gop_cache    off;
queue_length 0.1;
mw_msgs      0;
  1. 编码端配置:GOP 由默认值调整为 8(关键帧间隔 ~550ms),H.264 profile = Main,enc_queue 容量 1(约 68ms 缓冲),stream_queue 容量 2(约 137ms 缓冲)。

    注意 GOP 调整存在权衡:初期将 GOP 设为 2(~140ms),I 帧体积为 P 帧的 5-10 倍,频繁 I 帧产生码率峰值,低延迟播放器(缓冲仅 0.5s)无法平滑峰值,表现为帧率下降。调整为 GOP=8 后码率更均匀,延迟和帧率同时达标。

    结果

播放器 延迟 帧率
PotPlayer 3-4s(播放器自身缓冲,无法绕过) 14.6fps 正常
自研 RambosPlayer ~0.5s 14.6fps 正常

结论:RTMP 延迟由服务端 gop_cache、服务端队列、网络传输、播放器缓冲四层叠加构成,需逐层排查;GOP 过小虽降低首帧延迟,但导致码率峰值,对低延迟播放器有害;fps 配置必须与采集设备实际帧率一致,否则 PTS 错乱会引发一系列隐性问题。


6. 最终架构

┌─────────────────────────────────────────────────────┐
│                   AlertGateway                       │
│                                                      │
│  采集线程      推理线程      检测处理线程              │
│  V4L2      →  RKNN NPU  →  检测结果汇总+上报         │
│  /dev/video   YOLOv8s             │                  │
│                    │              │                  │
│                    ▼              ▼                  │
│               编码线程        MQTT线程               │
│               MPP硬编        检测结果上报             │
│               h264_rkmpp     Paho MQTT              │
│                    │                                 │
│                    ▼                                 │
│               推流线程                               │
│               RTMP推流                               │
└────────────────────┬────────────────────────────────┘
                     │
        ┌────────────┴────────────┐
        ▼                         ▼
  SRS(Windows)            MqttMonitor(Windows)
  RTMP接收                   检测结果实时显示
        │
        ▼
  RambosPlayer(Windows)
  拉流播放(含检测框画面)

6.1 最终指标

指标 数值
单帧端到端推理耗时 ~35ms(NPU 推理本身 ~30ms)
编码推流帧率 14.6fps(摄像头实际帧率)
RTMP 端到端延迟 < 1s
断线重连 支持,3 秒间隔无限重试

7. 总结

本文记录的 9 个问题中,3 个属于交叉编译 ABI 不匹配(FFmpeg、RGA、paho-mqtt-cpp),可归纳为同一类问题的不同表现;INT8 量化精度丢失问题(2.2 节)需要深入到 ONNX 图结构层面才能定位根因,是本项目排查难度最高的一个问题;Pipeline 解耦(第 3 节)是影响最大的架构级修复。

可复用的工程经验:

  1. 交叉编译统一使用板子实际运行的 .so 作为链接 stub,头文件版本与运行库版本严格对应

  2. 排查 NPU 推理精度问题时,先用 strings 确认模型量化类型,再检查 YOLO 类模型的 box/cls 分支是否被错误合并量化

  3. 多线程 pipeline 中速度不一致的环节使用独立队列解耦,允许丢帧的环节不应阻塞不允许丢帧的环节

  4. 长连接服务必须实现断线重连,且需处理重连期间下游队列的消费问题,避免背压

  5. 接入摄像头等硬件后应实测确认其真实可达参数,不依赖驱动声明值

    完整代码与全部 9 个问题的详细记录见仓库:github.com/johnjiamzhong-project/AlertGatewayBUGS.mddocs/ 目录)。

posted @ 2026-06-19 17:17  rambos1996  阅读(79)  评论(2)    收藏  举报