做视频智能分析的时候,最常被问的一句话就是:结果怎么给到业务端?在我用DeepStream C++做实时推理的这段时间里,把推理结果编码成视频流,再通过RTSP协议推出去,是我觉得最稳妥、也最通用的方案。RTSP是监控行业的事实标准,主流的播放器、硬盘录像机、上墙平台都能直接拉流,业务方不需要额外开发客户端,也不需要理解DeepStream内部那一堆buffer和bins。这篇文章就围绕“DeepStream C++ RTSP流输出”展开,聊一聊我从零搭建这条链路的思路、代码骨架和踩过的坑。
如果你正准备把自己的DeepStream C++工程从“只看到控制台结果”升级成“能实时看到带标注的推理画面”,这篇文章应该能帮你少走不少弯路。
1. 为什么要在DeepStream里做RTSP流输出:场景与选型
先聊我自己的几个真实场景。某智慧园区项目里,管理人员需要在Web端看到摄像头实时画面,同时值班室的大屏要能看到同一路视频叠加了检测框和计数信息,而且不能额外采购一套流媒体服务器。某工业质检Demo里,产线旁边就放着一台工控机,希望操作员用VLC就能打开检测画面,而不是跑到后台去看日志。做DeepStream C++二次开发的时候,只要把最终输出从“弹一个窗口”改成“推一路RTSP流”,这些需求就能统一满足。
1.1 哪些场景真的需要推成RTSP流
不是所有项目都需要这么做。如果你的结果只给算法同学看,直接用一个nvdsosd后的xvimagesink弹窗就够了,还能省掉编码和网络传输的CPU/GPU开销。但下面这些情况,RTSP基本是首选:
- 多终端同时观看:同一路视频要在大屏、电脑、手机、平板都能看,RTSP配合VLC或ExoPlayer都很方便。
- 需要和历史系统对接:很多视频管理平台、汉化版NVR、上墙解码器都只认RTSP/ONVIF,其他协议反而要套一层转换。
- 分析结果要叠加到原画上:检测框、标签、轨迹这些信息通过OSD叠加后,再编码成新的视频流,业务侧不用自己做渲染。
- 边推理边转发:DeepStream本身就是多路视频处理框架,推理和RTSP输出可以同时跑,不需要单独占用一台机器做视频分发。
反过来说,如果只是临时调试,或者需要低延迟互动(比如远程控制机器人),RTSP不一定是最好答案。那种场景下,WebRTC或者自定义UDP私有协议会更合适。RTSP的优势是通用性,不是绝对的实时性。
1.2 对比Python封装,C++方案的实际收益
很多人会问:DeepStream不是有Python binding吗?用Python写pipeline不是更快吗?我不否认Python在快速验证时的效率,但我在生产项目里最终都会落回C++,原因有几个。
第一,DeepStream的Python binding实际上是对C++底层GStreamer插件的封装,遇到自定义插件、特殊buffer、高性能多路场景时,Python层容易成为瓶颈。第二,C++能直接拿到NvDsBatchMeta的指针,处理自定义解析逻辑更顺手。第三,部署环境大多是嵌入式工控机或者服务器,C++编译出来的二进制依赖更少,没有Python解释器和一堆pip包分发的问题。
当然,C++的代价是开发速度慢,编译调试周期长。所以我的做法是:先用deepstream-app的配置文件验证整个pipeline能不能跑通,确认nvinfer、nvdsosd、编码器这些环节都没问题,再用C++代码逐步替换掉配置文件中那些环节。这样既能享受C++的执行效率,也不会被漫长的调试拖死。
1.3 RTSP输出方式的粗对比
真正常见的RTSP输出方式有三种,我整理过一个对比表格:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
直接把rtspclientsink接进GStreamer管道 | 代码量最小,pipeline结构简单,DeepStream插件链天然支持 | 需要外部RTSP服务端先存在,且只适合推流,不方便做鉴权和会话管理 | 内网环境、固定拉流地址的Demo |
| 使用DeepStream原生RTSP Server库 | 由SDK管理会话和流,API较稳定,能处理多路输出 | 版本更新后API有变化,调试时日志不够直观 | 正式业务、多路RTSP输出 |
| 自己起一个RTSP服务端,再接appsink取帧 | 最灵活,能自定义认证、抓包、转协议 | 开发量最大,要处理RTP打包、时间戳、丢包重传 | 特殊需求,例如需要把RTSP再转成其他协议 |
结合项目节奏,我建议多数人走第二种方案,也就是DeepStream自己的RTSP Server。它和你用C++写的推理逻辑在同一进程里,不用额外维护一个服务端进程,资源分配和生命周期管理更容易做。
2. 环境与依赖准备:版本匹配是最大的隐形坑
我刚接触DeepStream C++开发时,以为最难的是业务代码逻辑。真正开始搭建环境才发现,最花时间的是搞定SDK版本、驱动版本、GStreamer插件版本这三者之间的匹配关系。稍微错一组,编码器或者RTSP服务端就初始化失败,而且报错信息往往很隐晦。
2.1 组件版本怎么选才能少走弯路
这里我不直接给“下载哪个版本”,因为每个项目硬件平台不一样,网上说法也会变。我只分享一套排查方法,这套方法到现在救了我好几次。
先确认你的物理平台是什么架构。x86服务器、ARM嵌入式板卡、还是带特定加速模块的整机。然后根据这个平台选择对应的DeepStream镜像或者安装包。安装完后,依次检查:
deepstream-app --version能正常输出SDK版本。gst-inspect-1.0 nvinfer能看到插件信息,证明DeepStream的GStreamer插件库已经注册成功。gst-inspect-1.0 nvv4l2encoder能看到编码器插件,证明硬件编码器驱动正常。- 随便拿一个录像文件的路径,跑一下
deepstream-app -c里自带的Demo配置,确认能出结果。
这三条检查过了,环境基本就稳了。我之前跳过第3步,直接跑C++代码,结果nvv4l2encoder插件一直是NULL pointer,折腾了一下午才发现是SDK安装时没把Encoder插件链装进去。
2.2 容器部署和交叉编译时的注意事项
很多生产环境会用Docker。DeepStream官方镜像里已经编译好了GStreamer插件和DeepStream库,但你有两个关键点要额外注意。
一是GPU适配层。容器里必须挂载对应驱动和特定的设备节点,否则解码器和编码器都会失灵。我的经验是,起容器时除了常规的--gpus all之外,还要把/dev/nvidia-*设备和/etc/nvidia目录都挂进去,并确保容器里能用NV相关的监控命令看到卡。
二是C++开发头文件路径。直接在镜像里写C++代码,通常头文件都在/opt/deepstream/sources/includes下,链接时需要用pkg-config找到对应的库。我习惯把SDK的include目录和lib目录写进CMakeLists,避免每次编译都手动拼接-I和-L。这看着是小事,但换机器之后,忘了改路径会导致一堆稀奇古怪的链接错误。
如果你要交叉编译,我的建议是先不要在开发机上直接编,而是把整个编译环境做成一个独立的容器,保证SDK版本和运行镜像一致。交叉编译的坑比你想的要多,最常见的是运行时动态库加载路径不一样,编译时能过,放到板子上跑就报cannot open shared object file。这时候用ldd查一下每个依赖库的路径前缀,基本能找到问题。
3. 核心链路拆解:从推理到编码再到RTSP推送
搞清楚环境之后,最关键的是理解整个pipeline的数据流动。很多初学者看到DeepStream的例子就忙着抄代码,结果抄完了还是不懂为什么后面接的不是输出窗口而是RTSP服务端。
3.1 管道中RTSP输出节点的位置设计
一条典型的DeepStream C++ RTSP输出管道,我会这样设计:
视频源 -> 解码器 -> nvstreammux -> nvinfer -> nvdsosd -> nvvideoconvert -> 队列 -> nvv4l2encoder -> h264parse -> RTSP服务器关键点在于nvvideoconvert。推理、叠加之后的数据往往还在NVMM内存里,编码器需要的是特定颜色格式的NVMM buffer。如果你直接用nvdsosd的输出接到编码器,可能会因为颜色格式不匹配导致编码器初始化失败。加一个nvvideoconvert能显式定义输出格式,我一般会加上src/caps="video/x-raw(memory:NVMM), format=I420",让下游编码器读起来最舒服。
h264parse也不是可有可无的节点。硬件编码器输出的是裸H264数据,但RTSP服务端在封装RTP包的时候,需要把SPS/PPS以及关键帧信息解析出来。h264parse会帮你生成符合RTP封包要求的码流格式,没有它很多播放器能打开画面但花屏,或者几秒才出图。
还有一个容易忽略的是queue。推理链路和编码链路的速度不可能完全一致,一旦编码器因为网络阻塞暂时慢下来,上游推理如果直接使用阻塞式直连,整个pipeline都会被拖住。在nvvideoconvert和编码器之间加一个queue,相当于给两个环节之间加了缓冲池,实测下来能明显降低偶发卡顿导致的“丢帧连锁反应”。
3.2 C++里怎么组装这三段
GStreamer本身是基于GstElement和GstPad来组装的,DeepStream也不例外。我自己写C++代码时,不会一个插件一个插件地手动创建,那样代码太多且容易漏掉pad caps。我更推荐先用gst_parse_launch把完整的pipeline描述成字符串,然后获取需要动态设置属性的关键元素句柄。
这样做的好处是:第一,pipeline的拓扑结构一目了然,对后人维护友好;第二,DeepStream官方的配置文件本来就是按这种拓扑组织的,你能直接把配置文件的片段平移过来;第三,只在必要的地方用代码设置属性,比如bitrate、profile、RTSP端口等。
gst_parse_launch本质上还是GStreamer,不是魔法。它创建的element同样是暴露在总线上的,你照样可以连接gst_bus_add_watch去监听错误、警告和EOS事件。C++开发可以把它当成一个“拓扑构造器”,效率比逐行add_element高不少。
4. 关键代码实现:一个能跑起来的C++ RTSP输出示例
前面讲了一堆原理,这里给一个能落地的骨架。因为DeepStream不同版本API有差异,我不会贴一整套像官方源码那么长的代码,而是截取几个关键片段,说明每个片段的作用。你把它拼起来后,配合项目里的pipeline配置文件就能直接跑。
4.1 初始化推理管道并拿到视频帧
先构造推理和OSD部分的pipeline。我的经验是,不要让RTSP服务器相关的代码太早参与pipeline构造,先确保源、解码器、推理、OSD这段能正常输出NVMM帧,再接着做编码和RTSP。
GstElement *pipeline = gst_parse_launch( "filesrc location=/opt/sample.mp4 ! qtdemux ! nvv4l2decoder ! " "nvstreammux name=mux batch-size=1 width=1920 height=1080 ! " "nvinfer config-file-path=/opt/config_infer.txt ! " "nvdsosd ! nvvideoconvert ! " "video/x-raw(memory:NVMM), format=I420 ! queue ! " "nvv4l2encoder bitrate=4000000 ! h264parse ! " "appsink name=enc_sink", &error);这里我用appsink先把编码后的数据拿出来。为什么不直接接RTSP Server?因为在真正对接RTSP前,我建议先验证编码器输出的H264数据是不是正常的。你可以从enc_sink拿buffer,直接放到一个本地文件里,然后用播放器验证。这一步排除了“编码器配置错误”这个因素,后续排查时范围会小很多。
如果这一步能出来正常视频文件,说明从解码到编码的链路是通的。接下来只需要把appsink收到的那一段段编码后的数据流转交给RTSP服务端线程即可。
4.2 编码器参数和RTSP服务器的绑定
拿到编码后的数据后,我推荐用DeepStream SDK自带的RTSP Server组件,而不是自己去写RTP打包。核心代码大概是这样的思路:
// 创建RTSP服务实例,监听8554端口 NvDsRTSPServer *rtspServer = nvds_rtsp_server_new(8554, 10); // 把编码器输出端的pad挂到RTSP server创建的media上 // 这里需要告诉server:数据流是H264,来自哪个pad nvds_rtsp_server_add_stream(rtspServer, "stream", encoderPad, NvDsEncoderType::H264); // 启动服务 nvds_rtsp_server_mainloop_start(rtspServer);这段代码在不同版本的DeepStream里命名会不太一样,但核心逻辑不变:创建一个会话,告诉RTSP库“我这个pipeline里有一个H264的源”,然后它内部会完成RTP封包、RTSP握手、PLAY响应。
这里你可能会问:为什么不直接在pipeline里接rtspclientsink?因为那样还需要另一个外部RTSP服务端存在,而nvds_rtsp_server_*这组接口是把RTSP服务端集成到当前进程里,不需要额外进程。生产环境里我更愿意这样干,因为端口、鉴权、会话数量都能直接在C++代码里控制。
4.3 客户端断连时的回调处理
很多初学者只关心“怎么把流推出去”,很少关心“客户端断了怎么办”。实际上客户端一多,有人频繁打开关闭VLC,你的RTSP服务端如果没有做好断连清理,内存和会话句柄会慢慢泄漏,跑几个小时后就出怪问题。
DeepStream RTSP Server通常支持设置回调函数,比如客户端连接建立、断开、播放状态变化。我给自己的工程里加了断连处理:
void on_rtsp_connection_close(NvDsRTSPClientInfo *info, void *ctx) { // 打印客户端IP和会话ID,方便核对连接状态 g_print("client disconnected, session id: %s\n", info->session_id); } // 注册回调 nvds_rtsp_server_set_connection_close_cb(rtspServer, on_rtsp_connection_close);实际项目里我在这个回调里做了两件事:一是把会话ID同步到播放状态表,下次有人拉流时能看到当前活跃连接数;二是当连接全部断开时,可以选择降低编码器码率,避免空转浪费GPU。这个优化很实用,尤其是一天只有几段固定时间观看的场景,能省不少资源。
4.4 一个最简单的调用流程
把上面的片段串起来,调用流程一般是:
gst_init(nullptr, nullptr); // 1. 创建并启动DeepStream推理pipeline GstElement *pipeline = create_deepstream_pipeline(); gst_element_set_state(pipeline, GST_STATE_PLAYING); // 2. 初始化RTSP服务 NvDsRTSPServer *server = create_rtsp_server(8554, 10); add_stream_to_server(server, "stream", encode_pad, H264); set_disconnect_callback(server, on_disconnect); // 3. 进入主循环 rtsp_server_mainloop_start(server);实际工程里,我会加一个退出flag,收到SIGINT后先停RTSP服务,再置空pipeline状态,最后释放所有资源。顺序反了很容易在退出时出现段错误。
5. 延迟与画质的调优经验
RTSP流输出最大的两个指标就是延迟和画质,它们往往是矛盾关系。为了把延迟降下来,你要在丢帧策略和编码参数之间做取舍;为了把画质提上去,码率又必须足够,延迟也会跟着升。下面说几个我实测过的调优点。
5.1 端到端延迟的构成与压测数据
我用自己的Demo pipeline做了个简单压测,从摄像头拍到手,到最终VLC看到图像,大约分四段延迟:
- 采集解码延迟:取决于解码器和输入源,约20-60ms
- 推理与OSD延迟:取决于模型复杂度和有没有批处理,约30-200ms
- 编码缓冲延迟:受
264enc内部帧间预测结构和insert-sps-pps影响,约50-300ms - 网络传输和播放器缓冲:受带宽和播放器缓冲策略影响,不可控范围最大
我调整了编码器属性,把bitrate从8Mbps降到4Mbps,同时把iframe-interval从默认的几十帧调成30或60,实际能感觉到画面出屏速度更快。原因是关键帧率提高后,播放器不需要等太久就能开始解码。注意iframe-interval的单位是帧数,不是秒,需要结合视频帧率换算。比如30帧/秒的源,设成30就大约一秒一个关键帧。
如果你要追求更低延迟,可以在appsink和播放器两端都开启“低延迟模式”。VLC里把网络缓存从默认的1000ms以上调到300ms左右,效果会很直观。但那是播放器行为,不是你能控制的服务端行为,所以部署时最好和业务方说清楚。
5.2 画质、码率、分辨率的取舍
RTSP流输出有一个很常见的错误:以为码率越高画质越好。其实在硬件编码器里,码率只决定目标大小,具体编码质量还取决于编码器的预设、profile、以及画面内容复杂度。
我的经验是分三步走:
- 先保证分辨率够用。如果是1280x720的分析画面,你硬要推到4K,码率得跟着翻好几倍,否则细节全是噪点。
- 再调profile。一般用
High或Main,兼容性和画质比较平衡。遇到老旧的播放器才考虑Baseline。 - 最后调码率。以1080p为例,我一般从4Mbps起步,画面噪点多就加到6Mbps,纯静态画面就降到3Mbps。
另外,DeepStream的编码器前通常需要一个nvvideoconvert来设置src-caps。如果你希望RTSP画面上叠加的字体和框更清晰,不要一味提升码率,检查一下nvdsosd的字体大小和边框粗细效果比改码率实在多了。我踩过这个坑,以为码率低导致文字糊,最后发现是OSD本身的字体分辨率太低,换成24号字体立刻清楚了。
5.3 动态调码率的一个小技巧
多路输出时,GPU利用率其实是动态的。我后来在工程里加了每隔5秒检查活跃RTSP连接数的逻辑,有连接时用6Mbps,没有连接时降到2Mbps。这样既不牺牲观看体验,也避免一直在为无人观看的流做高码率编码。这个需求用C++写很简单,只需要周期调用对应API获取连接数,再修改编码器属性即可。理论上也可以用GStreamer的GstObject上属性通知机制,但是定时轮询更直接,代码更可控。
6. 踩坑记录:黑屏、卡顿、鉴权失败的前因后果
这部分内容比前面所有代码加起来都值钱。我遇到的这几个问题,几乎每个项目都会出现,而且它们都不是“看报错就能秒懂”的。
6.1 黑屏十有八九是编码器格式不匹配
有一次,我pipeline明明能跑起来,日志里没有错误,但客户端拉流的画面一直是黑色。我先是查了摄像头、查了OSD层,最后才发现是编码器的输入格式不对。
排查链路我分享一下:
- 先用
gst-launch-1.0单独跑编码器链路,接一个测试源,看能不能编码并显示。这步能确定编码器本身有没有问题。 - 再用
gst-inspect-1.0 nvv4l2encoder查看src-caps支持的格式,发现它只接受NVMM内存里的I420/NV12格式。 - 检查pipeline里
nvvideoconvert的输出caps,我写成了video/x-raw,没有指定memory:NVMM。结果它输出的是普通CPU内存,编码器拿到后只能吐黑帧。 - 改成
video/x-raw(memory:NVMM), format=I420后立刻正常。
黑屏这个问题的关键不是“加一个nvvideoconvert就完事了”,而是必须确认它输出的内存类型和颜色格式符合编码器的硬性要求。每次换平台或换SDK版本时,这个caps都要重新验证。
6.2 卡顿掉帧不一定在网络,先在本地验证
还有一次用户反馈说“画面每隔几秒卡一下”。我当时第一反应是带宽不够,把码率从4M降到2M,情况有所缓解但没根治。后来我在工控机本地用测试程序直接拉流,发现本地也卡,这就说明问题不在网络传输,而在编码或采集侧。
检查下来,发现是nvstreammux设置的batch-size和实际输入路数不一致。当时做了4路输入,但批处理大小还停留在默认的1,推理积压导致队列周期性溢出丢帧。把batch-size改成4,并确保内存池足够后,卡顿基本消失。
这里给大家一个排查卡顿的思路顺序:
- 先看CPU/GPU占用率,是不是推理跑满了。
- 再看队列有没有丢包,用GStreamer的
queue属性里的leaky参数,临时把队列设成极大,观察是否缓解。 - 再在网络两端各挂一台机器互拉,判断是不是网络本身就拥塞。
- 最后才是调码率降分辨率这些“最没技术含量但偶尔有效”的手段。
6.3 鉴权与URL路径问题
RTSP服务端可以配置用户名密码,但DeepStream的RTSP Server默认并没有强制鉴权。我在一次部署时以为加了账号密码就一定安全,结果测试发现密码不对也能看到画面。
后来搞清楚,鉴权分为两层:RTSP会话层的Basic/Digest认证,以及媒体层的播放控制。这就要求我必须在服务端回调里自己校验拉流URL中的用户名和token。有的SDK版本会在URL路径中携带鉴权信息,比如rtsp://user:pass@ip:8554/stream,但并不是所有播放器都会在发送PLAY请求时带上这个信息,所以不能完全依赖URL里的userinfo。
一个比较稳妥的做法是:在on_client_connect回调里检查客户端的IP白名单,然后结合URL路径区分公共流和私有流。如果业务上必须密码认证,再实现一个简单的token校验函数,在收到DESCRIBE请求时验证。注意,这里面有一个法律安全的问题:不要把RTSP服务暴露到公网,除非你做了完整的网络层防护。我实践中的建议是,RTSP服务只监听内网地址,通过跳板机或安全网关再出去。
6.4 编码时间戳错乱导致的花屏
最后一次比较难查的是花屏,不是黑屏也不是卡顿。画面偶尔出现马赛克块,而且集中在画面底部。排查发现是nvstreammux输入的帧时间戳不是单调递增的。后来在处理采集源时,我发现不同路视频源的起始时间不同,在混流阶段没有对齐时间基准。解决方式是统一给nvstreammux设置frame-duration,或者在多个源的时间戳上做一次基准偏移。这属于源拉流层面的问题,但也直接影响RTSP封包的正确性。
7. 从单路到多路:资源分配与拓展思路
RTSP流输出做完单路之后,很多人自然会想到多路。DeepStream的价值也在于这一步,多路视频可以共用一个推理引擎,推理效率远高于每路单独跑一个模型。
7.1 多路RTSP输出的架构选择
多路RTSP输出有两个方向:一是多路输入汇成一路RTSP,二是多路输入对应多路RTSP。
前一种适合做“多画面拼墙”,你在nvstreammux之前给每一路输入都叠加一个ROI区域,然后用一个大的画布拼起来。编码器只要编码一路4K拼接画面,输出一个RTSP流即可。这样延迟和带宽都很好,缺点是各路画面叠加的检测信息都混在一起,业务侧如果想单独提取某一路就麻烦一些。
后一种更适合“每路都独立观看”,例如每个摄像头一个独立流地址。做法是给每个流创建独立的nvds_rtsp_server_add_stream,把对应编码器pad挂进去。需要注意的是,多路编码器会同时占用硬件编码器会话,如果你的硬件编码器并发能力有限,就要在解码或推理侧加锁,避免统一时刻并发量过大。我的实践是优先用纹理拼接,再输出多路流,能明显降低编码器负载。
7.2 从RTSP再向WebRTC延伸
RTSP输出稳定之后,我又遇到过客户要求在浏览器直接看画面,不装VLC。浏览器原生并不支持RTSP,必须通过一个WebSocket或WebRTC协议中转。
我试过用标准GStreamer库把RTSP流再转成WebRTC流,也试过直接把DeepStream编码后的H264送入WebRTC库。前者链路清晰,但会多一次转发延迟;后者减少了中间环节,但要自己处理RTP和ICE协商,开发量陡增。
如果业务需求不那么强,我建议还是让客户端先用VLC或者带RTSP支持的播放器。如果需要WebRTC,优先考虑在服务端把RTSP拉流后通过独立的WebRTC网关转换,而不是在DeepStream进程里塞太多网络模块,否则排查问题时会分不清是推理卡顿还是网关卡顿。
7.3 资源分配的几条个人经验
最后分享几条个人经验,都是我试错换来的:
- 编码器是硬件资源,不属于GPU并行度问题。你的AI推理可能占用GPU很高,但硬件编码器有自己的会话数上限,务必先查清楚平台参数。
- 每条RTSP流尽量绑定独立的队列长度。不要把队列总量设得过大,否则会导致端到端延迟飙升。
- 多路流的日志要做好区分。我习惯在日志里带流ID和客户端IP,不然多路同时崩溃时,看日志根本分不清是哪一路引起的。
- 定时做端到端拉流校验。不是只部署时验证一次,而是每天定时从服务端拉一帧画面,检查编码时间戳和画面内容是否正常。这能快速发现长时间运行后的内存泄漏和会话堆积问题。
这条路我还在继续跑,RTSP输出只是DeepStream C++工程里的一个环节,后面遇到新的坑,再回来记录。