☰
FFmpeg+OpenCV:Qt工程实现RTSP/RTMP/UDP视频推流实战
2026/10/26 17:35:55 网站建设 项目流程

简介:基于FFmpeg与Qt的OpenCV帧数据推送方案,解决cv::Mat格式视频帧实时输出至RTSP、RTMP、UDP广播流的问题,适用于课程设计、毕业设计、期末大作业及项目开发。资源包为zip格式,共12个文件,以5个cpp源码与4个h头文件为核心,cpp负责视频采集、编码及多协议推送,头文件声明模块接口,另有vcxproj、filters工程配置和README说明文档,整体仅16KB,结构清晰便于阅读与调试。推送模块独立封装,可直接编译运行,也能抽取到自有项目中复用;针对UDP广播、RTSP、RTMP等场景均提供对应实现,代码经过严格测试,附带的README介绍了目录结构与使用方式,帮助开发者快速定位关键流程。目前已有467人学习下载,适合具备OpenCV基础、需要快速搭建视频流推送通道的开发者参考。

1. 用FFmpeg把OpenCV的Mat帧推成RTSP/RTMP/UDP流:这个Qt工程到底做了什么

做视觉检测的人都会撞到同一堵墙:OpenCV处理好画面只会在本地show,别人看不到,手机上看不到,网页上更看不到。这个项目解决的正是这最后一公里——用FFmpeg把OpenCV的cv::Mat帧推成RTSP、RTMP、UDP广播三种标准视频流,而整个工程跑在Qt框架下,源码包里rtsp_pusher、rtmp_pusher、udp_pusher三个推送模块加一个video_cap采集模块,结构清楚到可以直接把功能透析出来塞进自己的项目。适合期末大作业、课程设计和入门级项目开发,尤其是那些「OpenCV做完检测还要把画面实时发布出去」的场景。接下来我按拆包的顺序把这条链路掰开讲。

2. FFmpeg推流链路与工程结构:avformat如何和video_cap协作

2.1 FFmpeg推流的主线:从AVFormatContext到av_interleaved_write_frame

拿到这套代码时我第一反应是去找它有没有把推流框架写歪。整体看下来,主干没有偏离FFmpeg的标准推流套路,任何一路推送都逃不开下面这条链路:

// 伪代码,描述推流主线 AVFormatContext* fmt_ctx = NULL; avformat_alloc_output_context2(&fmt_ctx, NULL, "rtsp", url); // 设置输出参数,比如 rtsp_transport、gop 等 AVCodecContext* codec_ctx = create_video_codec(fmt_ctx); avio_open(fmt_ctx->pb, url, AVIO_FLAG_WRITE); avformat_write_header(fmt_ctx, NULL); while (采集到一帧) { // 1. cv::Mat -> AVFrame(像素格式转换) // 2. avcodec_send_frame(codec_ctx, frame); // 3. avcodec_receive_packet(codec_ctx, packet); // 4. av_interleaved_write_frame(fmt_ctx, packet); } av_write_trailer(fmt_ctx);

提示:avformat负责的是封装格式与协议层,avcodec负责编码,二者通过AVPacket沟通。理解这条主线后,再看这个项目里的任何pusher都不会晕。

avformat_alloc_output_context2的第一个参数传NULL时,FFmpeg会根据第二个参数指定的"rtsp"或"flv"格式名称自动匹配封装器,这么做的好处是把「封装」和「协议」绑定在一起处理。RTSP和RTMP走的就是这条标准路径,区别只在format_name和协议参数不同。而UDP广播那一路是单独写的,后面单独讲。

再看选择这个架构的理由。把采集、编码、推送分成三层,是我最认可这套代码的地方:video_cap只管输出cv::Mat,pusher不关心画面是摄像头来的还是视频文件解码出来的,编码器不关心推流目标在哪。这点对于课设项目尤其重要——能分离的职责尽量分离,后面改任何一层都不至于把整锅粥翻掉。

2.2 三个pusher为什么分开写,而不是一个万能类通吃

用一个大类同时处理rtsp、rtmp、udp当然可以,但很快就会变成一个大泥球。这套代码包里的做法是三个独立模块:rtsp_pusher.cpp、rtmp_pusher.cpp、udp_pusher.cpp,每个模块有对应的.h头文件。这么做最直接的好处是协议特有的参数不会互相污染。

RTSP有rtsp_transport这个特殊参数(传输走TCP还是UDP),RTMP需要处理flv封装和live流模式,UDP广播则压根不走avformat标准推流路线。把这些差异摊到三个类里,每个类只需要维护自己那套参数,阅读成本低很多。

// rtmp_pusher.h 的大致结构 class RtmpPusher { public: bool open(const std::string& url); bool pushFrame(const cv::Mat& frame); void close(); private: AVFormatContext* fmt_ctx; AVCodecContext* codec_ctx; AVStream* stream; // SwsContext、AVFrame、AVPacket 成员 };

三个类对外暴露的接口几乎一致,都是open/pushFrame/close三件套,内部差异被藏起来了。这种设计给我一个启发:你在自己的项目里复制这套代码时,完全可以用一个抽象基类或者接口把这几个pusher统一起来,调用方拿一个指针就能切协议,这就是典型的策略模式用法。对做课设的同学来说,答辩时能讲清楚「为什么这样拆分」往往比讲清楚「这段代码怎么写的」更拿分。

2.3 video_cap模块:把采集从推流中剥离开的巧思

video_cap.h和video_cap.cpp单独存在,是这套工程里最容易被忽略但最值得借鉴的部分。这个模块只做一件事:封装OpenCV的VideoCapture,对外稳定输出cv::Mat。初始化时可以指定用摄像头索引还是视频文件路径,内部循环读取每帧画面传给推流端。

这个抽象为什么值得单独拆?因为痛过的人都懂。如果直接把VideoCapture和推流逻辑写在一起,头一个麻烦是换数据源就得改推流模块的代码;另一个麻烦是OpenCV的VideoCapture在某些USB摄像头上打开后可能有延迟,这个延时补偿逻辑写在采集端比写在推流端自然得多。

// video_cap.h 头文件的核心接口 class VideoCap { public: bool open(int device_id, int width, int height); bool open(const std::string& video_path); bool read(cv::Mat& frame); // 读取一帧,成功返回true void release(); };

我拿到代码时注意到它把「读取成功与否」的判定暴露给调用方,这让上层可以在读不到帧时选择推黑帧还是直接跳过。实测中经常出现摄像头被占用导致read返回false的情况,如果没有这一层返回,整个推流线程会直接卡死。在课程设计场景里这层保护可以让你从容地在UI里弹一个「摄像头打开失败」而不是程序无响应。

3. cv::Mat进AVFrame的必经之路:像素格式转换与pts时间基

3.1 BGR到YUV420p:SwsContext的创建与单次调用

OpenCV默认读进来的是BGR排列的三通道数据,而H.264编码器几乎只吃YUV420p(以及有限的几个格式)。这个项目在进入编码循环前会创建好一个SwsContext,把每帧cv::Mat数据从BGR24转成YUV420p再塞进AVFrame。这一块是整条推流链路上最容易被忽略、也最容易出诡异bug的地方。

// 创建像素格式转换上下文 SwsContext* sws_ctx = sws_getContext( frame_width, frame_height, AV_PIX_FMT_BGR24, // 输入:OpenCV默认BGR frame_width, frame_height, AV_PIX_FMT_YUV420P, // 输出:H.264编码器标准输入 SWS_BILINEAR, NULL, NULL, NULL );

参数说明:前两组宽高通常一致,不需要缩放;SWS_BILINEAR是双线性插值算法,纯推流场景用SWS_FAST_BILINEAR性能更好,画面缩放需求基本没差别。这里最需要注意的是源像素格式必须写成AV_PIX_FMT_BGR24而不是 RGB24,写错不会报错,但推出来的画面红蓝通道整体对调,肤色变青,排查起来相当费劲。

3.2 AVFrame的内存和linesize:data[0]不是你想的那样

很多从OpenCV转过来的人会犯同一个错误:直接把frame.data指针赋给AVFrame的data[0],然后指望编码器能读走。这套代码里用的是稳妥做法——先给AVFrame分配好buffer,再用sws_scale把数据灌进去。

// 初始化AVFrame AVFrame* frame = av_frame_alloc(); frame->format = AV_PIX_FMT_YUV420P; frame->width = width; frame->height = height; av_frame_get_buffer(frame, 32); // 32字节对齐 // 每帧拷贝:把Mat数据丢给sws_scale int srcSliceH = sws_scale(sws_ctx, (const uint8_t* const*)&mat.data, // 输入data指针 &mat.step, // 输入linesize 0, height, // 从第0行开始,处理整个高度 frame->data, frame->linesize); // 输出到AVFrame

逻辑说明:av_frame_get_buffer会根据width、height和像素格式自动分配YUV三个平面的内存,32字节对齐保证后续硬件编码器不会挑刺。sws_scale的倒数第二组参数是输入平面数组和每行字节数,OpenCV的Mat用&mat.step作为linesize是正解。这里有个细节——mat.step是字节数,不是像素数,BGR24三通道下width=1920的图step是5760,直接写frame_width必然是花屏。

注意:sws_scale的返回值和srcSliceH请顺手做一次大于0的校验,返回0说明转换失败,这时候还继续往下走编码器会喂进去一帧空数据。

3.3 pts和时间基准:为什么推流卡顿先怀疑这里

FFmpeg推流和本地另存为文件有个决定性的不同:流接收端是实时播放的,不认你自己心里想的帧序号,只认pts(显示时间戳)。这个项目里对pts的处理特别老实:手动用av_rescale_q换算到输出流的时间基。

// 利用自增帧号计算pts int64_t pts = av_rescale_q( frame_count * (1000000 / fps), // 以微秒为单位的相对时间 AVRational{1, 1000000}, // 输入时间基:微秒 stream->time_base // 输出流时间基 ); frame->pts = pts; frame_count++;

参数说明:av_rescale_q把「从开始到现在经过的微秒数」换算成FFmpeg这个流内部使用的时间基刻度。换算的核心是当前帧的理论时间点,千万别直接写frame_count然后就当pts用,因为当时间基不是1/1的时候,编码器会把逐帧递增的1、2、3当成1秒、2秒、3秒,推出去的就是幻灯片。fps是采集帧率,1000000/fps就是一帧占的微秒数。如果采集帧率不稳,这个计算方式会漂,更稳的做法是拿系统高精度时钟直接换算,但课设和一般开发场景下按固定帧率算完全够用。

3.4 编码器上下文:H.264参数与关键帧间隔

工程里对编码器参数的设置集中在create_video_codec内部,几个关键的参数值对推流体验影响极大。视频编码这一层的取舍,直接决定接收端看到的延迟和流畅度。

codec_ctx->codec_id = AV_CODEC_ID_H264; codec_ctx->bit_rate = 2000000; // 2Mbps码率 codec_ctx->width = frame_width; codec_ctx->height = frame_height; codec_ctx->time_base = {1, fps}; codec_ctx->framerate = {fps, 1}; codec_ctx->gop_size = fps * 2; // 每两秒一个关键帧 codec_ctx->max_b_frames = 0; // 关掉B帧 codec_ctx->pix_fmt = AV_PIX_FMT_YUV420P; av_opt_set(codec_ctx->priv_data, "preset", "veryfast", 0); av_opt_set(codec_ctx->priv_data, "tune", "zerolatency", 0);

参数说明:bit_rate的2Mbps是1080p25帧推流比较中庸的起点,画面里人不动可以降到1Mbps,画面里纹理多就加到4Mbps。gop_size设成fps*2的好处是接收端最多等2秒就能等到关键帧完成画面恢复,但也意味着带宽平均多花一点。max_b_frames设为0关闭B帧对实时推流是必要的——B帧需要参考未来帧,会额外引入延迟且打乱播放顺序。preset和tune这两个参数在libx264下最影响实时性,veryfast和zerolatency是低延迟推流的黄金组合。

4. RTSP/RTMP/UDP三路推流的参数细节与验证命令

4.1 RTSP推送:传输模式与SPS/PPS的去伪存真

RTSP推流在这套代码里走的是标准avformat流程,关键参数在avformat_new_stream之后、avformat_write_header之前设置。工程里rtsp_pusher.cpp把rtsp_transport参数通过AVDictionary传进avformat_write_header,这个细节直接影响能不能被标准播放器正常拉流。

# 验证RTSP流:指定tcp传输模式拉流 ffplay -rtsp_transport tcp rtsp://127.0.0.1:8554/live/stream

很多人在这一步会犯一个先入为主的错:自己手动把SPS/PPS塞进每一帧的AVPacket里。RTSP协议自己会通过信令通道(ANNOUNCE/SETUP阶段)协商SPS/PPS,不需要你在媒体数据里额外填充。工程代码里没有对RTSP流做过度的extra data操作,直接把编码器输出原样写入av_interleaved_write_frame就够。传输模式上TCP更稳但延迟略高,UDP延迟低但在弱网下丢包就直接花屏。工程里默认提供TCP模式,遇到跨网段拉流建议优先用TCP。

4.2 RTMP推送:flv封装与live模式的配对

RTMP推流的目的地通常是一个RTMP服务器(比如SRS、nginx-rtmp),FFmpeg在做RTMP输出时实际上玩的是flv-over-rtmp的把戏。这套代码里的rtmp_pusher.cpp用avformat_alloc_output_context2时把格式名指定为"flv",而不是"rtmp",这是个非常正确的细节——FFmpeg根本没有名为rtmp的muxer,RTMP复用器的内部实现就是flv muxer加上RTMP协议层。很多初学者在这里写"rtmp"查文档半天发现不存在的报错。

// rtmp_pusher.cpp 中打开输出的关键参数 AVDictionary* opts = NULL; av_dict_set(&opts, "flvflags", "no_duration_filesize", 0); // 实时流不写时长元数据 av_dict_set(&opts, "rtmp_live", "live", 0); // 声明这是直播流 av_dict_set(&opts, "rtmp_buffer_size", "4096", 0); // 发送缓冲,单位KB ret = avformat_write_header(fmt_ctx, &opts);

参数说明:rtmp_live这个键会在握手时向服务器声明这是一个live类型的流,服务器会据此使用直播缓存策略,而不是当成点播流等整个文件结束才允许播放。rtmp_buffer_size调高能减少突发网络拥堵时的丢包,但调太高会增大延迟。flvflags的no_duration_filesize避免flv头里的duration字段误导播放器做点播行为。这三项参数是这个工程里RTMP能稳定推给SRS的关键。

# 验证RTMP流:直接用ffplay拉流 ffplay rtmp://127.0.0.1/live/test # 用ffprobe看收到流的编码参数 ffprobe -v quiet -show_streams -select_streams v rtmp://127.0.0.1/live/test

4.3 UDP广播:为什么这里不走avformat标准流,以及mpegts-over-udp

这大概是用这套代码的人最容易困惑的一路。udp_pusher.cpp没有走上面的标准链路,而是选择更底层的做法:先完成H.264编码拿到AVPacket,然后把裸H.264数据拆成NALU直接扔给UDP socket。原因在于FFmpeg的udp muxer是个极其特殊的存在,它本身不做任何封装,写进去的数据原样发给目标地址,播放端拿到的是裸流,没有SPS/PPS的情况下几乎无法自动解码。

工程里udp_pusher的做法是把每帧的SPS/PPS和IDR帧打包在一起发出去。UDP本身不保证包顺序和送达,所以它和TCP那套SPS/PPS只在连接初始化时发送一次的思路完全不同,必须每条关键帧都带上参数集,否则接收端等到下一个IDR才能恢复画面。

// udp_pusher.cpp 发送H.264裸流的核心逻辑 // 1. 从AVPacket中解析出NALU(跳过start code,识别SPS/PPS/IDR类型) // 2. 把SPS、PPS、以及当前帧的NALU按顺序拼进同一个send buffer // 3. 用sendto()发出 if (is_key_frame(pkt)) { buffer.append(sps_data, sps_len); buffer.append(pps_data, pps_len); } buffer.append(pkt->data, pkt->size); sendto(udp_fd, buffer.data(), buffer.size(), 0, ...);

一个更省事的替代方案是在应用层不做裸流,而是用FFmpeg的mpegts封装后通过udp输出:avformat_alloc_output_context2指定"mpegts",URL写udp://127.0.0.1:1234。mpegts本身自带节目映射表,播放端不需要你手动拼SPS/PPS也能自动发现流。代价是TS封装多了一层头部开销,UDP包的有效载荷变小。我建议项目如果只是课堂演示优先用mpegts-over-udp,如果要学习和展示H.264底层结构再保留裸流方案。

# 验证mpegts-over-udp流 ffplay udp://127.0.0.1:1234 # 验证裸H.264 UDP流(VLC手动指定参数) # VLC打开网络串流,输入: # udp://@:1234 # 高级选项里取消“实时”,或者在播放前先用ffmpeg确认到达的数据可解码

4.4 三路流的统一验证套路:先命令行后代码

拿到工程后我建议的动手顺序是先编一个最小验证程序,把命令行工具全部跑通,再嵌入这套代码。因为命令行工具报错信息比你自己写的错误处理完整得多。ffplay拉流、ffprobe查参数、tcpdump抓包看UDP有没有到,这四件事加一起五分钟能完成,能排除掉一大半网络和格式问题。

# 优先做三件事 # 1. 确认目标服务器连通 ping 192.168.1.100 # 2. 用命令行测试FFmpeg推一个测试源 ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=25 \ -c:v libx264 -preset veryfast -tune zerolatency \ -f rtsp rtsp://192.168.1.100:8554/live/test # 3. 如果这条命令通了,说明网络和服务器没问题,问题就在自己代码的参数上

这个顺序能帮你快速缩小范围:命令行推不通是环境问题,命令行推得通而你代码推不通,就是参数没设对。这套代码的四个模块里,坑最深的还是第5章的这些场景。

5. 推流避坑指南:SRS延迟、UDP黑屏、偏色的六个真实踩坑记录

5.1 现象:推到SRS的RTMP流延迟持续变大,最终变成十几秒

原因:GOP设置过大加上B帧累积,播放端为了纠正时间戳会缓冲越来越多的帧。SRS默认会做gop cache,但缓存策略和GOP大小强相关。代码里gop_size如果按默认值250设,25fps下就是10秒一个关键帧,这部分延迟观众是无法接受的。

解决:把gop_size设成fps的2倍左右,同时max_b_frames=0关闭B帧,preset用veryfast。改完以后我用秒表实测,端到端延迟稳定在1到1.5秒之间,这是纯软件编码方案的正常水平。如果你的场景要求毫秒级,那需要走硬件编码这条路,工程里这套代码没有覆盖。

5.2 现象:UDP裸流推出去,VLC黑屏,ffplay提示no decoder

原因:H.264裸流不带SPS/PPS,播放端在流中间加入时拿不到解码所需的序列参数集,无法初始化解码器。桌面端的VLC偶尔能靠猜参数碰巧解出来,但绝大多数情况是黑屏。

解决:按第4.3节的做法,在每个IDR帧前面拼上SPS和PPS再一起发送。改进后再用VLC的udp串流播放就稳定了。如果你不想手工处理NALU,直接用mpegts-over-udp替代裸流方案,问题自动消失。

5.3 现象:窗口里OpenCV画面颜色正常,推流出来整体偏蓝或偏红

原因:sws_getContext的输入格式写了AV_PIX_FMT_RGB24,而OpenCV的Mat默认内存布局是BGR24,这两个通道顺序是反的。FFmpeg转换时不认识颜色名字,只知道按字节位置取数据,于是红色通道和蓝色通道被互换。

解决:把源格式参数全部改成AV_PIX_FMT_BGR24。识别这类问题有个窍门:如果画面里肤色和红色的物体变成青色、蓝色,基本就是通道顺序反了。这个坑我第一次写链接时踩过一次,从那以后每次写sws_getContext都会停一秒确认srcFormat是不是BGR24。

5.4 现象:程序运行几十秒后崩溃,错误信息指向av_interleaved_write_frame

原因:AVPacket使用后没有正确释放引用计数。av_interleaved_write_frame会拿走packet的内部引用,你下次循环av_packet_alloc时其实复用了同一块内存,数据被覆盖导致崩溃。另一处是循环结束时av_read_frame/avcodec_receive_packet返回错误码但没有break,死循环把内存吃完。

解决:每次写完帧立刻av_packet_unref,在avcodec_receive_packet返回AVERROR(EAGAIN)时跳过当前帧而不是报错退出,处理完所有输入后调avcodec_send_frame(NULL)把缓冲flush出来。工程代码里对这两种情况都做了保护,你复制模块时别把这层省了。

// 每帧推流后必须做的清理 av_packet_unref(packet); // 释放引用计数 if (ret == AVERROR(EAGAIN)) continue; // 编码器缓冲未满,假装无事发生

5.5 现象:RTSP流拉到一半断掉,或者只有声音没有画面

原因:要么是RTSP的传输模式不一致,拉流端用TCP推流端强制UDP,中间NAT把UDP丢了;要么是编码器产生的SPS/PPS变化了但RTSP信令没有重新协商。换分辨率、换帧率这类操作必须在stop推流后重新打开,动态改AVCodecContext参数不会生效,还会把码流结构搞乱。

解决:推流前把分辨率、帧率固定下来,不要中途改;拉流端指定和推流端一致的传输模式。如果必须动态切换清晰度,先close再open,重新走一遍avformat_write_header。

5.6 现象:RTMP推到nginx-rtmp能看,推到SRS就黑屏

原因:工程里的RTMP缓冲参数rtmp_buffer_size小于一帧关键帧的大小。SRS在握手时会把buffer size写入chunk size协商,如果值太小,IDR帧会被强制拆成大量小块发送,部分播放器等待完整帧组装超时就放弃显示。nginx-rtmp对这块处理宽松,所以只有SRS出问题。

解决:把rtmp_buffer_size调到至少4096,同时检查码率与buffer的匹配关系:2Mbps码率下,一个GOP的瞬时大小可能超过1MB,buffer至少留2到4倍余量。这个问题在局域网几乎不会暴露,跨公网推流时立刻现形。

6. 进阶:把pusher重构成你自己的通用推流组件的做法

如果你不是做课设,而是想把这套能力沉淀成团队里能复用的基础设施,建议直接做一次接口收敛。三个pusher对外接口高度一致,这一步成本非常低。收敛后上层业务只需要一个抽象指针,不需要关心当前用的是RTSP、RTMP还是UDP。

// 统一抽象接口示例 class IVideoStreamPusher { public: virtual ~IVideoStreamPusher() = default; virtual bool open(const std::string& url) = 0; virtual bool pushFrame(const cv::Mat& frame) = 0; virtual void close() = 0; }; // 使用侧代码 std::unique_ptr<IVideoStreamPusher> pusher; if (url.find("rtsp://") == 0) pusher = std::make_unique<RtspPusher>(); else if (url.find("rtmp://") == 0) pusher = std::make_unique<RtmpPusher>(); else if (url.find("udp://") == 0) pusher = std::make_unique<UdpBroadcastPusher>(); pusher->open(url);

做完这个抽象之后,推流模块就和业务彻底解耦了。我会在pushFrame内部补一个简单的帧率控制——用steady_clock计算每帧耗时,不足一帧间隔就sleep补齐。这能避免采集源帧率高于推流帧率时把CPU白白耗在无意义的编码上,也避免接收端看到忽快忽慢的画面。另外值得做的是把日志统一成「打点」风格:open成功耗时多少毫秒、首帧pts是多少、累计丢帧数多少,这些信息在排查线上问题时比任何调试器都好用。

我每次在新的机器上跑通推流,都会强制自己走一遍验证流程:ffprobe看流信息、ffplay播三十秒、再检查首帧pts是否接近0。这三步全绿才认为这次改造真正完成。这套代码本身在工程上给了一个很扎实的起点,剩下的坑都需要你亲手踩一遍才能变成自己的经验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询