ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RK3588边缘AI盒子实战:从RTSP拉流到YOLO推理再推流全流程解析

RK3588边缘AI盒子实战:从RTSP拉流到YOLO推理再推流全流程解析 做边缘AI盒子这几年有一个感受特别深模型训练出来只是开始真正折磨人的是把模型丢到板子上跑通一整套视频业务。上一篇文章有人留言问能不能在RK3588上把拉流、解码、推理、编码、推流整条链路串起来正好我们最近的项目就是这个形态顺手把第三篇写出来。先说清楚这条链路到底长什么样。摄像头出RTSP流板子从流里拉出H.264/H.265裸数据交给RK3588的VPU硬件解码拿到YUV帧后丢进NPU跑YOLO检测结果直接画到原图上再经过VPU硬编码成新的H.264码流最后推到ZLMediaKit分发出去。整个过程没有任何一步经过CPU软件编解码这也是在RK3588上做低延迟视频管线的核心思路。如果你也在做类似的边缘计算盒子、安防巡检设备、或者直播检测一体机这篇文章应该能帮你省下不少调研时间。这篇不去重新讲ZLMediaKit基本部署和YOLO原理重点落在几个容易翻车的环节MPP解码的缓冲管理、RKNN的输入格式约束、VPU编解码与NPU推理之间怎么接力、以及全流程线程模型怎么设计。所有代码片段都是我们从实际项目里抽出来的简化版本拿过去改改就能用。1. 这条路线的核心技术逻辑为什么是“解码→推理→编码→推流”而不是更简单的方案很多新手拿到RK3588第一反应是用FFmpeg软解OpenCV调用NPU跑YOLO再用FFmpeg软编码推流。这条路确实最简单代码一写就跑通但跑起来之后你才会发现CPU占用率高得离谱四路视频直接卡成PPT。RK3588的CPU是有4个A76大核没错但软解1080P H.265大概就要吃掉一个半大核软编码又要吃一两个核NPU推理也要搬运数据到最后CPU根本忙不过来还谈什么多路并发。1.1 硬件编解码在RK3588上的地位RK3588内部集成的VPU模块支持非常完整的视频编解码H.265和H.264的8K解码、8K编码都能硬扛4路1080P实时解码对VPU来说非常轻松。关键在于VPU是一套独立硬件单元它干活的时候不占用CPU算力CPU只负责下发指令和搬运控制信息。这意味着你的A76核心可以专心跑业务逻辑、跑预处理、跑后处理编解码压力完全交给VPU。MPP就是Rockchip官方提供的这套VPU的软件抽象层。它的全称是Media Process Platform提供了一组纯C接口用来操作VPU做解码、编码、转码。RK3588的Linux SDK里自带MPP源码编译好之后就是librockchip_mpp.so和librockchip_mpp_venc.so直接用就行不需要额外装什么第三方库。1.2 ZLMediaKit在链路中扮演的角色ZLMediaKit在这条链路里的角色比较特殊它既是拉流客户端又是推流服务器。上游它可以从摄像头的RTSP地址拉流替我们把网络传输的脏活累活干完返回给我们的是纯码流数据下游它又是一个完整的流媒体服务器我们把处理后的H.264码流喂回去它就帮你完成RTSP/RTMP/WebRTC的分发甚至自动转封装。你用浏览器看、用VLC拉流、或者用手机App扫码看它都给你处理好了。选择ZLMediaKit而不是自己做RTSP服务器的原因很现实RTSP协议看着简单但要做完善需要处理RTP打包、TCP/UDP模式切换、鉴权、SDP协商一堆细节。ZLMediaKit把这些都做完了而且用C写的在嵌入式平台上性能表现很好内存占用也不夸张。用一句话概括它把视频业务里最烦琐的网络传输层封装好了我们只需要关心视频数据本身。1.3 为什么是“解码后推理再编码”而不是“拉流后直接改码流”有人可能会问能不能不解码直接在H.264码流上做检测答案是目前不可能。YOLO这类深度学习目标检测模型的输入是像素矩阵而H.264/H.265码流是一长串经过预测编码、变换量化、熵编码之后的二进制数据两者之间差了十万八千里。你必须有解码这一步把码流恢复成图像帧才能让NPU“看”到画面内容。编码这一步同样绕不开。NPU推理完成后得到的是一个个检测框坐标和类别这些信息要变得“可看”必须绘制到帧上然后重新编码成码流推出去。如果只是做纯检测不画框理论上可以不编码直接转发原始码流但那个场景就变成旁路分析了跟本文讲的“带检测结果的视频推流”是两条路线。2. 环境准备与基础组件编译板子选型、SDK版本和不可避免的坑正式开始之前先把底层的环境搭建讲清楚。这部分如果出问题后面所有环节都会跟着出问题而且排查起来特别隐蔽。2.1 板卡与系统选择我们用的是RK3588的标准开发板不是某个特定的商业品牌但市面上的RK3588板子基本都能跑下面的流程。系统层面有两个选择一是用Rockchip官方的Ubuntu固件这个对VPU、NPU、MPP、RKNN的兼容性最好也是我们最终采用的方案二是用第三方社区系统比如Armbian。Armbian对RK3588的支持已经比较成熟但要留意内核里VPU驱动和MPP版本是否跟你的业务代码匹配。我的建议是如果你只是做应用层开发直接选官方Ubuntu固件省心。如果你需要深度定制内核或者做启动优化再考虑Armbian。一个很常见的坑是系统自带的MPP库版本太旧导致调用新的编码接口时出现异常而官方固件里MPP和内核版本是配套测试过的踩坑概率低很多。2.2 ZLMediaKit编译过程中的几个关键点ZLMediaKit编译依赖openssl、libsrtp、ffmpeg可选这几个库官方文档写得很清楚我这里只强调几个容易踩的地方。第一务必确认CMake版本高于3.10不然有些新特性不支持。第二如果要支持WebRTC推流需要在CMake时额外开启ENABLE_WEBRTC这个选项默认是关的而且需要libsrtp库。第三编译时指定Release模式Debug模式跑起来性能损失很大。下面是我们验证过的编译命令# 安装基础依赖 sudo apt-get install -y build-essential cmake openssl libssl-dev libsrtp2-dev # 克隆并编译 git clone https://github.com/ZLMediaKit/ZLMediaKit.git cd ZLMediaKit mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease -DENABLE_WEBRTCON make -j$(nproc)编译完的可执行文件在release/bin/MediaServer。启动之前改一下config.ini把http.port、rtsp.port、rtmp.port这些端口设置成你实际要用的值。验证服务是否正常直接浏览器访问http://板子IP:端口/index/api/getServerInfo能看到JSON返回就是起来了。2.3 MPP和RKNN运行库的获取MPP通常在官方SDK的external/mpp目录下编译整个SDK工程量比较大如果你用的是官方Ubuntu固件大概率系统里已经预装了MPP运行库。用ldconfig -p | grep mpp查一下如果能找到librockchip_mpp.so就可以直接用。RKNN运行时库需要用RKNN-Toolkit2工具链从模型生成RKNN格式文件然后部署的时候把librknnrt.so放到板子上。这个库在Rockchip提供的RKNN SDK包里能找到版本要和模型转换时的版本保持一致。我们用的是RKNN-Toolkit2 1.6.0版本对应的runtime librknnrt.so也是1.6.0版本不一致会出现ERROR: RKNN runtime version mismatch。3. 拉流接入从摄像头RTSP到拿裸码流环境准备好之后第一步就是把摄像头的视频流接进来。这里有两种主流做法我用表格把它们的区别列清楚。方案实现方式优点缺点ZLMediaKit代理拉流用ZLMediaKit的API添加流代理从/index/api/addStreamProxy拉流和后续推流在同一个进程共享内存数据效率高代码量少依赖ZLMediaKit的协议栈出问题排查需要理解ZLMediaKit内部机制FFmpeg自写拉流用libavformat接口自己拉流然后喂给MPP灵活可控不受ZLMediaKit代码约束代码量大需要自己处理网络抖动、重连、音视频同步两种方案我们都试过。最开始用FFmpeg自写拉流因为觉得可控性高但实际跑起来发现ZLMediaKit的代理拉流机制在弱网环境下的表现反而更稳定它有自动重连机制而且可以直接复用ZLMediaKit内部的RTP解析结果省去自己处理RTCP和重传的逻辑。最终我们采用了ZLMediaKit代理拉流的方案。3.1 用HTTP API添加流代理ZLMediaKit提供了一套HTTP API用起来非常顺手。添加一条RTSP拉流代理只需要一个POST请求curl -X POST http://127.0.0.1:8080/index/api/addStreamProxy \ -d vhost__defaultVhost__applivestreamch1urlrtsp://admin:password192.168.1.64:554/Streaming/Channels/101这个接口会启动一个后台协程从指定的RTSP地址拉流然后以live/ch1这个流ID注册到ZLMediaKit内部。如果你只是想验证拉流是否成功直接用VLC打开rtsp://板子IP:10554/live/ch1就可以看了。但我们要做的是在拉流过程中把数据取出来做处理而不是单纯地做转发。这时候就需要写一个小的C程序用ZLMediaKit的API来注册回调拿到解码前的纯码流数据。3.2 注册MediaSource事件拿码流ZLMediaKit支持在播放器或拉流代理建立之后通过注册MediaSource的事件监听来获取码流数据。核心思路是拿到MediaSource对象然后注册一个MediaSourceEvent的子类在onReaderChanged或onTrackReady里拿到Track对象再调用Track::addDelegate注册码流回调。核心代码大概长这样#include Http/HttpSession.h #include Player/PlayerProxy.h #include Common/MediaSource.h #include Rtsp/RtspMediaSource.h using namespace toolkit; using namespace mediakit; class FrameHandler : public MediaFrameDelegate { public: void onTrackFrame(const Frame::Ptr frame) override { // 在这里拿到H.264/H.265裸码流 // frame-data() 是码流数据 // frame-size() 是数据长度 // 直接交给MPP解码器去处理 handleFrame(frame-getCodecId(), frame-data(), frame-size()); } }; PlayerProxy::Ptr proxy std::make_sharedPlayerProxy( live, ch1, 0.0f, true, false); proxy-setPlayCallbackOnce([proxy]() { auto src MediaSource::find(RTSP_SCHEMA, defaultVhost, live, ch1); if (src) { auto track src-getTrack(TrackVideo); if (track) { auto delegate std::make_sharedFrameHandler(); track-addDelegate(delegate); } } }); proxy-play(rtsp://admin:password192.168.1.64:554/Streaming/Channels/101);这段代码的核心逻辑是先用PlayerProxy去拉流拉流完成之后找到对应的MediaSource和视频Track然后注册帧回调。这样每次从摄像头收到一帧H.264数据就会进到onTrackFrame里。拿到数据后的事情比如解码就是下一节的内容了。3.3 拉流延迟与缓冲参数处理实话说ZLMediaKit默认的拉流策略偏重稳定性RTSP拉流会带着一定缓冲。如果你对延迟要求高需要在PlayerProxy创建时调整参数把rtp_rtcp_timeout_sec和media_timeout_sec适当调低同时设置max_rtp_queue_size避免网络抖动时在ZLMediaKit侧堆积大量RTP包。我们实际项目里对延迟的要求是端到端不超过800毫秒这个目标靠后面的整体优化完成拉流这一环先把buffering控制到合理范围。ZLMediaKit的PlayerProxy有单独的_demand参数设置为0.0f代表不主动追赶时间戳可以在拉流时通过构造函数传入。上文代码里第三个参数0.0f就是这个含义。4. MPP硬件解码把码流变成YOLO能吃进去的帧拉到的裸码流是H.264或H.265的编码数据直接丢给NPU肯定不行。解码这一步我们用MPP的API来做硬件解码。4.1 MPP解码器的初始化MPP解码的整体流程可以概括为初始化上下文、绑定解码器类型、配置输入输出、循环发送码流包、循环获取解码帧。初始化部分对于H.264和H.265来说区别不大只要把编码器类型换成MPP_VIDEO_CodingAVC或MPP_VIDEO_CodingHEVC就行。#include rk_mpi.h // 创建解码器上下文 MppCtx ctx nullptr; MppApi *mpi nullptr; MppDecCfg dec_cfg nullptr; // 初始化MPP mpp_create(ctx, mpi); MPP_RET ret mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); if (ret ! MPP_OK) { printf(mpp_init failed\n); } // 创建解码配置对象 mpp_dec_cfg_init(dec_cfg); // 开启分帧模式每一包必须是一帧完整数据 mpp_dec_cfg_set_u32(dec_cfg, split, 1); mpp_dec_cfg_set_u32(dec_cfg, split_mode, MPP_PACKET_SPLIT_MODE_ONE_BY_ONE); mpp_dec_cfg_set_u32(dec_cfg, split_out, 0); mpp_dec_cfg_set_u32(dec_cfg, split_debug, 0); mpp_dec_cfg_set_u32(dec_cfg, split_eos, 0); mpp_dec_cfg_set_u32(dec_cfg, split_mode, MPP_PACKET_SPLIT_MODE_ONE_BY_ONE); mpp_dec_cfg_set_u32(dec_cfg, split_out, 0); mpp_dec_cfg_set_u32(dec_cfg, split_debug, 0); mpp_dec_cfg_set_u32(dec_cfg, split_eos, 0); mpi-control(ctx, MPP_DEC_SET_CFG, dec_cfg);split配置非常关键。H.264/H.265码流在网络传输过程中会被切分到不同的RTP包里ZLMediaKit输出的数据可能不是完整的一帧。如果你不开启split模式MPP会把不完整的数据当作一帧送进解码器结果就是花屏或者解码器状态异常。开了split模式之后MPP内部会自动做帧边界检测只有攒够一帧数据才真正解码这样省去了我们自己拼帧的麻烦。4.2 MPP解码的输入与输出MPP解码的输入输出模型基于分层缓冲池设计。核心的调用逻辑是这样void decode_h264_frame(MppCtx ctx, MppApi *mpi, void *data, size_t size) { MppPacket packet nullptr; MppFrame frame nullptr; MppBuffer buffer nullptr; // 创建输入包携带一帧H.264数据 mpp_packet_init(packet, data, size); mpp_packet_set_pts(packet, current_pts); mpp_packet_set_eos(packet, 0); // 发送码流包到解码器 mpi-decode_put_packet(ctx, packet); // 从解码器取回解码后的帧 ret mpi-decode_get_frame(ctx, frame); if (MPP_OK ret frame) { if (mpp_frame_get_info_change(frame)) { // 分辨率变化或首次获取帧信息 handle_info_change(ctx, mpi, frame); } // 拿到YUV数据准备给NPU用 MppBuffer frame_buf mpp_frame_get_buffer(frame); void *yuv_data mpp_buffer_get_ptr(frame_buf); int width mpp_frame_get_width(frame); int height mpp_frame_get_height(frame); int hor_stride mpp_frame_get_hor_stride(frame); int ver_stride mpp_frame_get_ver_stride(frame); } if (frame) { mpp_frame_deinit(frame); } mpp_packet_deinit(packet); }这里有两个很容易忽略的细节。第一MPP_DEC_GET_FRAME拿到的是YUV数据默认格式是NV12。NV12的排列方式是先一整块Y平面然后是UV交错的平面。RKNN推理时模型输入常需要RGB格式这中间需要一步格式转换这个放到下一节详细说。第二解码器输出的hor_stride和ver_stride很可能和显示宽高不一样。RK3588的VPU要求内存对齐比如1920x1080的帧实际stride可能是1920甚至更宽分配内存时按stride来否则会越界。很多新手在这块翻车读出花屏或者内存越界崩溃十有八九是忽略了stride对齐。4.3 解码缓冲池与帧率控制MPP内部默认会缓存多帧解码结果原因是解码顺序和显示顺序可能不同。拉流场景下如果要做低延迟可以把解码缓冲池调小让解码器尽快吐帧。通过MPP_DEC_SET_CFG里的frame_buffer_num参数可以控制。我把这个参数调到4实测延迟降低明显而且没有出现解码能力不足的问题。如果你在4K或者8K场景下可以把缓冲池调大一点否则解码器会频繁阻塞等待空缓冲区帧率反而上不去。5. RKNN接入YOLO模型转换、推理调用与结果叠加解码拿到YUV帧之后下一步是送给RKNN跑YOLO推理。5.1 RK3588上跑YOLO的两种主流路线关于RK3588上部署YOLO大家最常用的有两套方案RKNN-Toolkit2直接部署以及ONNX Runtime RKNN EP。后者本质上也是走RKNN只是暴露出来的接口更接近ONNX生态。从性能角度讲两者差别不大但从可控性讲我更推荐直接用RKNN-Toolkit2因为你能精确控制输入的tensor格式、内存分配、以及零拷贝的开启方式。我们的模型是YOLOv8s训练用的PyTorch导出为ONNX之后用RKNN-Toolkit2转换成RKNN格式。转换过程中的关键参数from rknn.api import RKNN rknn RKNN() # 配置目标平台 rknn.config( target_platformrk3588, mean_values[[0, 0, 0]], std_values[[255, 255, 255]], optimization_level3 ) # 加载ONNX模型 ret rknn.load_onnx(modelyolov8s.onnx, input_size_list[[1, 640, 640, 3]]) # 构建RKNN模型 ret rknn.build(do_quantizationFalse, datasetdataset.txt) # 导出RKNN文件 ret rknn.export_rknn(yolov8s.rknn)这里do_quantization我们用了False。如果你做INT8量化模型体积会缩小到四分之一推理速度也会提升一些但对检测精度有影响特别是小目标容易丢失。对于视频流检测这种实时性要求高、目标尺寸变化大的场景我建议先用FP16跑通全流程后续再根据实际业务场景决定是否量化。5.2 YUV帧到RGB的格式转换RKNN的输入通常是RGB888格式而MPP解码输出的是NV12。从NV12转RGB这一步可以用CPU软件转也可以用RGA硬件转。RGA是Rockchip开发的2D图形加速硬件可以完成格式转换和缩放。一开始我们图省事用CPU软转结果发现一帧1080P的NV12转RGB大约需要15毫秒左右看着不多但跑四路视频就是60毫秒没有了CPU占用非常难看。改用RGA之后同样的转换只需要1~2毫秒而且不占用CPU算力。代码层面RGA的调用也不复杂核心是把输入输出buffer绑定到RGA会话上// 简化示意实际用librga的接口 #include rga.h int nv12_to_rgb(void *nv12_buf, void *rgb_buf, int w, int h) { RgaSURC_FORMAT src_format RK_FORMAT_YCbCr_420_SP; RgaSURC_FORMAT dst_format RK_FORMAT_RGB_888; int ret RgaBlit(nv12_buf, rgb_buf, w, h, src_format, dst_format, 0, 0, w, h, 0, 0, w, h); return ret; }5.3 推理前的前处理Letterbox与归一化YOLO系列模型的输入尺寸是固定的通常是640x640但解码帧的分辨率可能是1920x1080直接缩放会导致目标拉伸变形检测精度下降。标准做法是letterbox保持宽高比地把图像缩放再在两边填充灰色条填到640x640。RKNN的C接口里提供了rknn_input_set和rknn_run前处理得自己写。为了性能可以在转换模型时就把mean_values和std_values配置进去这样输入前只需要做letterbox归一化交给NPU侧完成。推理的调用链大概是rknn_input inputs[1]; inputs[0].index 0; inputs[0].type RKNN_TENSOR_UINT8; // 输入是uint8的RGB数据 inputs[0].fmt RKNN_TENSOR_NHWC; inputs[0].buf letterboxed_rgb_buf; // letterbox之后的rgb数据 inputs[0].size 640 * 640 * 3; rknn_inputs_set(ctx, 1, inputs); rknn_run(ctx, nullptr); rknn_output outputs[1]; rknn_outputs_get(ctx, 1, outputs, nullptr); // 拿到输出做后处理 rknn_outputs_release(ctx, 1, outputs);5.4 后处理框坐标反算与绘制YOLOv8的输出有几个格式rknn_outputs_get拿到的原始输出通常是[1, 84, 8400]这种布局需要做解码得到检测框。原理上每个检测框最终可以解码成(cx, cy, w, h, objectness, class_scores)的形式做阈值过滤和NMS之后得到最终结果。这块代码在RKNN官方示例里就有直接改改就能用我不过多展开。一个容易踩坑的细节是后处理拿到的检测框坐标是相对于letterbox之后640x640图像坐标系的要映射回原始1080P帧上必须做坐标反变换。这个反变换和letterbox的填充参数是配套的漏掉这一步会导致目标位置偏移或者框画歪。我曾经在这个问题上排查了整整半天最后发现是letterbox填充的偏移量计算错了。坐标反算出来之后把检测框和标签绘制到帧上。绘制这部分可以用CPU直接画因为检测框通常只有几个CPU开销可以忽略不需要上RGA。6. MPP硬件编码与推流把处理完的帧变回可播放的码流检测框画好之后帧内容已经是完整的结果画面接下来要把它编码成H.264或者H.265码流推给ZLMediaKit。这里同样用MPP的硬编码不走软编码。6.1 MPP编码器的配置MPP编码和MPP解码完全是两条API但结构类似。初始化编码器时需要指定编码格式、输入帧格式、宽高、帧率、码率等参数。MppCtx enc_ctx nullptr; MppApi *enc_mpi nullptr; MppEncCfg enc_cfg nullptr; // 创建编码器 mpp_create(enc_ctx, enc_mpi); mpp_init(enc_ctx, MPP_CTX_ENC, MPP_VIDEO_CodingAVC); // H.264编码 // 配置编码参数 mpp_enc_cfg_init(enc_cfg); mpp_enc_cfg_set_s32(enc_cfg, prep:width, 1920); mpp_enc_cfg_set_s32(enc_cfg, prep:height, 1080); mpp_enc_cfg_set_s32(enc_cfg, prep:hor_stride, 1920); mpp_enc_cfg_set_s32(enc_cfg, prep:ver_stride, 1080); mpp_enc_cfg_set_s32(enc_cfg, prep:format, MPP_FMT_YUV420SP); // NV12 mpp_enc_cfg_set_s32(enc_cfg, rc:mode, MPP_ENC_RC_MODE_CBR); // 恒定码率 mpp_enc_cfg_set_s32(enc_cfg, rc:bps, 4000000); // 4Mbps mpp_enc_cfg_set_s32(enc_cfg, rc:bps_max, 4000000); mpp_enc_cfg_set_s32(enc_cfg, rc:bps_min, 4000000); mpp_enc_cfg_set_s32(enc_cfg, rc:gop, 30); mpp_enc_cfg_set_s32(enc_cfg, rc:fps, 25); mpp_enc_cfg_set_s32(enc_cfg, codec:type, MPP_VIDEO_CodingAVC); mpp_enc_cfg_set_s32(enc_cfg, codec:level, 40); mpp_enc_cfg_set_s32(enc_cfg, codec:profile, 100); enc_mpi-control(enc_ctx, MPP_ENC_SET_CFG, enc_cfg);几个参数需要解释一下。prep:hor_stride和prep:ver_stride跟解码那边一样必须是宽高对齐之后的值一般我们直接填和宽高一样的值如果遇到VPU报对齐错误再往上补64的倍数。rc:mode我们选CBR恒定码率因为视频会议和安防场景里网络带宽可控恒定码率有利于延迟稳定。如果做录制存储VBR可能更省空间但延迟波动会大一些。rc:gop设置关键帧间隔30代表每30帧一个I帧对拉流播放器而言I帧间隔越短开启播放时的首屏时间越快。6.2 编码的输入把推理结果帧喂给编码器MPP编码器的输入是NV12格式的YUV帧但你在推理过程中往往用的是RGB格式。我们把RGB帧重新转回NV12这个转换同样可以交给RGA完成从RGA的转换方向列表里选RGB888到NV12即可性能开销很小。编码循环如下// 从编码器拿一个编码输入buffer MppBuffer enc_buf nullptr; MppFrame frame nullptr; mpp_frame_init(frame); mpp_frame_set_width(frame, 1920); mpp_frame_set_height(frame, 1080); mpp_frame_set_hor_stride(frame, 1920); mpp_frame_set_ver_stride(frame, 1080); mpp_frame_set_fmt(frame, MPP_FMT_YUV420SP); mpp_frame_set_buffer(frame, enc_buf); // 把NV12数据拷贝到encoder的输入buffer里 memcpy(mpp_buffer_get_ptr(enc_buf), nv12_data, 1920 * 1080 * 3 / 2); // 发送帧到编码器 enc_mpi-encode_put_frame(enc_ctx, frame); // 获取编码后的码流 MppPacket packet nullptr; enc_mpi-encode_get_packet(enc_ctx, packet); if (packet) { void *codec_data mpp_packet_get_ptr(packet); size_t codec_size mpp_packet_get_length(packet); // 这段H.264码流就是你要推送的数据 push_to_zlmediakit(codec_data, codec_size); enc_mpi-encode_packet_done(enc_ctx, packet); } mpp_frame_deinit(frame);这段循环中encode_get_packet可能会返回MPP_OK但packet为空这表示编码器还在内部排队你只需要继续循环等待。不要在这一层加不必要的延时MPP内部有自己的节奏你只要保证输入帧按时送进来就行。6.3 推流到ZLMediaKit编码出来的H.264裸码流推给ZLMediaKit常见的姿势有两种一种是我们直接拿ZLMediaKit做服务器用它的API推送另一种是自己起一个RTSP推流器往ZLMediaKit推。两种都有人用但既然我们在同一个进程里跑ZLMediaKit直接调用它的本地API是最省事的。ZLMediaKit的本地推流API思路是创建一个RtspMediaSource然后向它写入数据帧。简化代码如下#include Rtsp/RtspMediaSource.h using namespace mediakit; RtspMediaSource::Ptr media_src std::make_sharedRtspMediaSource(live, result); auto video_track std::make_sharedVideoTrack( CodecH264, 1920, 1080, 25, 4 * 1024 * 1024, 0); // 注册track和资源 media_src-addTrack(video_track); media_src-updateSelf(); // 每一帧编码完成之后把数据包装成Frame写入 auto frame std::make_sharedMediaFrame( CodecH264, codec_data, codec_size, 0, 0); video_track-addDelegate([frame](const Frame::Ptr ) { media_src-writeFrame(frame); });这样ZMLMediaKit就把live/result这个流准备好了任何客户端都可以通过rtsp://IP/live/result拉流观看。这里有一个容易被忽略的点VideoTrack的构造参数里包含帧率、码率等元信息ZLMediaKit会用这些信息来生成SDP。SDP写错会导致播放器协商失败最常见的现象就是VLC能播放但某些安防平台播放不了。我们的经验是帧率务必和实际编码帧率一致码率不用太精确但也不能偏差太远。7. 全流程线程模型与性能调优从能跑到跑得快把拉流、解码、推理、编码、推流每个环节单独做出来都不难难的是把它们优雅地组织在同一个进程里让它们并行跑起来互相不拖后腿。7.1 线程模型设计我见过很多人写这类程序时一个线程里做完所有事拉流回调里直接解码解码完直接在同一个线程里跑YOLO推理推理完直接编码推流。这个模型的问题很大因为整个流水线的耗时是各阶段耗时的总和一个阶段卡住整个管线都卡住。我们在项目里用的是类似生产者消费者的多线程模型按照数据流方向划分成三个线程组解码线程、推理线程、编码推流线程。解码线程只干一件事从ZLMediaKit的码流回调里拿裸码流送入MPP解码器拿到YUV帧后放入一个帧队列里然后立刻返回等待下一帧。推理线程从帧队列里取帧做RGA格式转换、letterbox、RKNN推理、后处理、绘制检测框然后把带框的NV12帧放入另一个队列。编码推流线程从第二个队列取帧送入MPP编码器拿到H.264码流后推给ZLMediaKit。线程之间的连接用无锁环形队列避免用锁导致的高开销。队列深度一般控制在3到5帧满了就丢弃最旧的帧。为什么要丢帧而不是排队因为视频业务里实时性比完整性重要得多如果队列堆满了还继续入队延迟会越来越大画面越来越卡最终的结果是用户看到的是越来越晚的画面这在安防场景里是不可接受的。7.2 零拷贝与内存复用RK3588上做视频管线一个容易被忽略的性能瓶颈是内存拷贝。解码出来的YUV帧在VPU分配的buffer里如果你拷贝一份给RGA做转换RGA转换完再拷贝一份给NPU整个过程就多了两三次冗余拷贝1080P每帧的数据量大约是3MB频繁拷贝对内存带宽的压力非常大。优化方案是尽可能让数据在硬件buffer之间流转避免经过CPU。MPP解码输出的是一个由MPP管理的MppBuffer它内部对应的是物理连续内存。RGA本身可以接受MppBuffer的物理地址作为输入输出NPU的rknn_input也可以直接指定用import模式把一个已经存在的buffer导入成NPU的输入tensor而不是把数据拷贝进去。具体用到的RKNN接口是rknn_create_mem_from_phys和rknn_set_io_mem这两个接口可以把物理内存导入为NPU的输入输出。其中细节比较多但核心思想很简单让数据在VPU、RGA、NPU这三个硬件单元之间通过物理地址流转CPU只负责调度。做了这一步优化之后单路1080P全流程的处理耗时从原来的120毫秒压到了70毫秒左右。7.3 实测性能数据为了给大家一个直观参考我把我们最后的实测数据列出来。测试条件是RK3588开发板官方Ubuntu固件单路1080P/30fps H.264输入YOLOv8s模型FP16推理输出H.264 1080P/25fps CBR 4Mbps。环节平均耗时占比拉流解封装2 ms2.9%MPP硬件解码4 ms5.7%RGA格式转换2 ms2.9%letterbox预处理3 ms4.3%RKNN推理40 ms57%后处理NMS8 ms11.4%绘制检测框3 ms4.3%MPP硬件编码5 ms7.1%推流1 ms1.4%其他开销2 ms2.9%合计70 ms100%从这组数据可以清楚看到瓶颈完全在RKNN推理上占了57%。如果你想压缩整体延迟优先做的事情应该是优化推理部分比如换更小的模型、做INT8量化、或者裁剪输入分辨率。硬件编解码各个环节加一起只有9毫秒左右几乎可以忽略不计。7.4 多路视频扩展思路RK3588标称NPU算力6TOPS单路YOLOv8s FP16推理占用大约40毫秒也就是说NPU理论上有余力处理更多路。但多路不是简单地把单路代码复制几份就行的需要处理NPU算力分配、VPU编解码通道复用、内存带宽上限等问题。我们的做法是用一个共享的RKNN上下文多路视频流共用同一个模型推理请求排到一个队列里按顺序执行。因为RKNN的NPU是串行处理推理请求的多路并发不会加速单路推理只会让推理请求排队。所以真正有效的是减少单路模型的计算量让它有更多余量给其他路。我们把模型换成YOLOv8n之后单路推理耗时降到25毫秒左右四路同时跑也只是轻微卡顿。8. 避坑指南调试过程中最常踩的五个雷跑通整条链路之后回头看调试过程有几个坑可以说是99%的人都会遇到专门拿出来说一下。8.1 RKNN运行时版本不匹配这是出现频率最高的问题。模型是在PC上用RKNN-Toolkit2转换的转换工具版本和板子上的librknnrt.so版本不一致跑起来会报错。解决方法是转换完模型之后去板子上运行strings librknnrt.so | grep version确认runtime版本如果和转换时不一致把板子上的librknnrt.so换成对应版本或者重新转换模型。8.2 MPP解码器不吐帧MPP解码器一切初始化正常但decode_get_frame死活返回空。排查后发现大部分原因是没把MPP_DEC_SET_CFG里的split模式配置正确。如果码流不是按帧边界送入的MPP内部一直在等待完整帧永远不吐帧。把split打开、保证送入的每个包都是一帧完整数据问题立即解决。8.3 编码输出分辨率被自动改变MPP编码器初始化时如果没正确设置prep:hor_stride和prep:ver_strideVPU可能按默认值比如4096x4096分配缓冲导致输出码流的分辨率异常。这个问题的排查比较隐蔽因为API接口不会报错只有在你检查编码后的SPS信息时才发现宽高值不对。解决方法是初始化编码器前打印一次配置确认所有prep参数都符合预期。8.4 RKNN输入过大的像素值抖动如果你跑FP16模型时发现检测精度和PC上差很多先检查输入图像数据有没有正确归一化。RKNN的mean_values和std_values配置影响非常大配置错会导致输入像素值范围不对模型看到的是完全不同的数据分布。PYTHON转模型时候的配置和C接口里传入的数据格式必须严格对应。我们有一次把mean_values配错成[[0,0,0]]而实际应该配置成按通道特定的归一化参数导致检测结果惨不忍睹。8.5 ZLMediaKit推流后播放端连不上这个问题多半是SDP信息不完整。ZLMediaKit的MediaSource如果缺少profile-level-id等关键字段部分播放器会拒绝拉流。解决方法是创建VideoTrack时把codec信息写完整特别是H.264的profile和level要设置合理。另一个常见原因是IP和端口绑定问题ZLMediaKit配置里rtsp.port默认绑定了所有网卡如果你改了端口记得同步开放防火墙。9. 从单路demo到可交付系统的几个进阶思考单路全流程跑通只是万里长征第一步。真正常见的问题都是在多路、长时间运行、弱网环境等真实部署场景中暴露的。长时间运行的稳定性是很多人会忽视的。MPP解码器和编码器的资源如果不正确释放跑几个小时就会出现内存持续增长最后OOM。我们做过一个简单的压力测试连续运行72小时每分钟记录一次内存和线程数。第一个版本跑了一天多就崩了排查发现是MPP编码器在encode_put_frame和encode_get_packet之间的异常分支里漏了解放buffer导致内存泄漏。可靠的做法是把MPP的每个调用包一层RAII风格的封装异常时自动释放资源。另一个值得思考的点是视频流的中断与恢复。摄像头断线、重启、码流分辨率变化都会影响整条链路。我们的做法是在ZLMediaKit的拉流事件里注册重连回调摄像头重新上线后拉流代理自动重连重连成功后重置MPP解码器状态。这个重置逻辑要非常小心解码器上下文最好直接销毁重建而不是只做flush不然容易出现隐性问题分辨率变了但解码器内部残留旧状态导致花屏。10. 结尾一些我个人调试这条链路的心得最后聊点实在的经验。如果你正在参考这篇文章搭建自己的方案我建议严格按照“先解耦、再串联”的顺序做。先把ZLMediaKit拉流和MPP解码这一小段跑通单独验证解码输出是否正常再接RKNN推理先不要画框只打印检测结果验证精度确认推理正确后再接MPP编码和推流。每一步都是可验证的出了问题能立刻定位到具体模块而不是在一堆代码里大海捞针。还有一个小技巧调试RKNN推理时可以把MPP解码出来的YUV帧保存成文件用FFmpeg转成JPG人工检查。这样能快速确认“输入给NPU的图到底对不对”而不是等到整个链路跑完才看到结果不对那时候你根本不知道是哪一步出了问题。整个方案做下来我对RK3588这个平台的判断是它的VPU硬编解码能力完全够用NPU的单路推理性能也符合预期真正需要花时间的是工程落地层面那些细节——内存管理、多线程调度、异常恢复、长时间稳定性。这些没有现成的教程只能靠实际项目一点一点磨出来。希望这篇文章能帮你少走一些我们走过的弯路。
返回列表