资讯动态

基于C++/Qt与FFmpeg的IPC/NVR实时视频流播放Demo工程解析

发布时间:2026/10/16 3:29:45 来源:尧图企业网站定制
简介一套面向大华IPC与NVR设备的视频播放示例核心是基于Dahua NetSDK完成Java后端对摄像机实时码流的拉取、处理与转发同时提供Vue前端界面适合安防监控开发者、Java后端工程师和高校相关专业学生用于学习与二次开发。压缩包共包含1852个文件整体约1.74MB其中1810个Java源码覆盖SDK调用、视频流转发、报警信息接收、参数配置等核心逻辑9个Vue组件负责页面展示配合XML、properties等配置文件构成带Maven构建标记的完整工程导入开发环境后目录结构清晰、便于定位功能模块。已有一千九百九十三人学习下载。通过该Demo可以掌握大华摄像头接入流程、NetSDK接口调用方式并理解RTSP/RTMP流媒体协议在浏览器播放中的应用还可学习身份认证、HTTPS加密、码流优化等安全保障手段对搭建和扩展网络视频监控平台具有实际参考价值。整体示例覆盖从设备接入到播放显示的完整链路适合作为NetSDK二次开发与视频监控系统学习的参考项目。1. 项目定位一个让 IPC/NVR 实时视频流直接上屏的播放 Demo做安防接入的开发者几乎都会遇到同一个需求把网络摄像头IPC或录像机NVR的实时视频流拉到自己界面里播放。这份 Demo 就是干这件事的样板工程——一个能编译、能双击运行、能看到画面的完整 C/Qt 工程。输入 IP、端口、用户名密码它能自动列出 NVR 通道选中即可实时预览还带抓图和本地录像。适用人群很明确给现有系统加视频能力的集成商做安防接入的上位机或嵌入式开发以及想搞懂 RTSP 拉流、FFmpeg 解码、Qt 上屏这条链路的初学者。它不依赖云平台局域网就能跑通。解码层用 FFmpeg接入层覆盖 RTSP/ONVIF 与厂商私有 SDK对二次开发比较友好。下面从工程结构开始逐步拆开讲。2. 整体架构从设备到屏幕的模块划分与线程模型这个 Demo 的模块划分按“接入、解码、UI”三层展开核心思想是设备协议与界面显示彻底隔离。接入层只负责拿到码流解码层只负责产出可显示的帧UI 层只负责把帧画出来三层之间的接口只有两三个类换设备、换界面都不至于动全身。2.1 技术选型为什么用 C/Qt 加 FFmpeg拿到“设备视频播放”这种需求面前有三条路。一是直接用厂商私有 SDK 的解码显示接口开发量最少但 UI 定制和跨平台都被厂商方案绑死。二是用 VLC 或 QtMultimedia 这类现成播放组件RTSP 拖进去就能放但做抓图、录像和按帧处理时很难插手。三是自建拉流解码链路也就是这款 Demo 的做法Qt 管界面FFmpeg 管协议解析和解码中间自己控制线程和帧缓冲。第三条路的优点在于链路每一环都看得见摸得着出了问题能逐步定位。FFmpeg 在安防领域生态太成熟RTSP 协议解析、H.264/H.265 解码、转封装都内置了网上相关踩坑记录也多。Qt 这边主要是 QImage 显示方便QWidget 和 QML 都能接。这个 Demo 把解码层与界面层彻底分开解码线程和 UI 线程之间只隔一个帧队列不互相直接调用这是后面所有功能扩展的基础。2.2 工程目录结构与模块职责工程按接入、解码、UI、工具四块组织结构如下IPCameraDemo/ ├── CMakeLists.txt ├── config.ini ├── src/ │ ├── main.cpp │ ├── core/ │ │ ├── DeviceManager.h/.cpp // 设备列表与登录态管理 │ │ ├── RtspStreamer.h/.cpp // RTSP 拉流线程 │ │ ├── Decoder.h/.cpp // FFmpeg 解码线程 │ │ ├── FrameQueue.h/.cpp // 跨线程帧队列 │ │ ├── Cvt.h/.cpp // YUV→RGB 转换 │ │ └── NvrSdkWrapper.h/.cpp // 厂商私有 SDK 封装 │ ├── ui/ │ │ ├── MainWindow.h/.cpp │ │ ├── ChannelListWidget.h/.cpp // 通道列表 │ │ └── VideoWidget.h/.cpp // 画面显示控件 │ └── utils/ │ └── IniConfig.h/.cpp └── third_party/ ├── ffmpeg/ └── sdk/DeviceManager 维护设备会话一个设备对应一个会话会话里保存实时流的运行状态。RtspStreamer 负责建立 RTSP 连接读到 AVPacket 后只做解复用不做解码把原始包丢给解码线程。Decoder 解码出 AVFrame经 FrameQueue 传递给界面。Cvt 负责把 YUV420P 转成 QImage 需要的 RGB32因为 Qt 不直接吃 YUV。NvrSdkWrapper 是唯一包含厂商 SDK 头文件的地方换一家设备厂商只需要替换这一层。UI 侧尽量保持无逻辑。VideoWidget 拿到一帧 QImage 就 update 重绘不关心 RTSP 和码流。通道列表控件展示设备查到的通道双击触发一次播放请求。这个分层让后文要做的回放、抓图都有地方挂靠不至于把功能塞进窗口类里变成大泥球。2.3 数据流与线程模型三个线程一个队一条实时预览链路从设备到屏幕其实是三个线程在接力拉流线程负责从 RTSP 读取 AVPacket解码线程把 AVPacket 变成 AVFrameUI 线程把 AVFrame 转成 QImage 画到窗口。数据流严格单向拉流线程往解码线程丢包解码线程把解出来的帧放进有界队列UI 线程用 QTimer 定时从队列取帧显示。关键点是 UI 线程绝不在解码回调里直接重绘否则碰到网络抖动时界面会跟着一起卡死。FrameQueue 是这个接缝处的核心实现用互斥锁加条件变量。入队和出队各有各的节奏解码线程可能一秒产生 25 帧UI 线程重绘频率最好锁在 20 到 30 帧所以队列支持“丢旧帧”——队列满时直接丢最老的帧保证播放延迟不会无限累积。AVFrame 的引用计数管理也在这层做释放统一走 av_frame_free避免关视频时崩溃。线程退出顺序同样要注意。关视频时先停拉流线程再停解码线程最后停 UI 线程顺序反了容易出现“解码线程还在读已经释放的上下文”这种玄学问题。Demo 里用一个 atomic 布尔量贯穿三个线程退出时按反序 join二次开发时这段别简化。2.4 帧队列的参考实现队列本身不长但在链路里的地位很关键精简版实现如下class FrameQueue { public: FrameQueue(int maxSize 30) : maxSize_(maxSize) {} bool push(AVFrame *frame) { std::lock_guardstd::mutex lock(mtx_); if ((int)q_.size() maxSize_) { av_frame_free(q_.front()); // 丢旧帧 q_.pop(); } q_.push(av_frame_ref(frame)); return true; } AVFrame *pop() { std::lock_guardstd::mutex lock(mtx_); if (q_.empty()) return nullptr; AVFrame *f q_.front(); q_.pop(); return f; } void clear() { std::lock_guardstd::mutex lock(mtx_); while (!q_.empty()) { av_frame_free(q_.front()); q_.pop(); } } private: int maxSize_; std::queueAVFrame* q_; std::mutex mtx_; };逻辑说明push 时如果队列满先释放最旧一帧再把新帧做一次引用计数持有后入队pop 把队首帧指针交给调用方调用方负责用完释放。这里有个使用前提——AVFrame 不能整体拷贝必须走 av_frame_ref否则解码线程里复用缓冲区会导致画面花甚至崩溃。clear 方法专供停止播放时清空残留解决了后面避坑章节里那个“关闭视频崩溃”的问题。maxSize 按 30 帧设计大约一秒多一点的数据量既能缓冲网络抖动又不至于把延迟拖太久。3. 设备接入RTSP 拉流与 ONVIF 发现的细节接入层解决两件事设备在哪地址是什么。一个局域网的项目里设备可能分布在多个网段账号密码也各式各样这一层做扎实了后面的播放都顺。3.1 RTSP URL 的拼装换设备厂最先看这里RTSP 地址是一切开端。各厂商规则差异较大但普遍支持带参格式rtsp://user:passip:port/streamPath?channelxsubtypey。Demo 里把路径作为参数传入避免把某个型号的私有路径写死在代码里std::string buildRtspUrl(const std::string ip, int port, const std::string user, const std::string pass, const std::string streamPath, int channel, int streamType) { return rtsp:// SimpleCvt::urlEncode(user) : SimpleCvt::urlEncode(pass) ip : std::to_string(port) / streamPath ?channel std::to_string(channel) subtype std::to_string(streamType); }这段代码的重点在两点。一是用户名密码做了 URL 编码密码里有、:、空格时不编码的 URL 会被解析错位这是接第三方设备第一个容易踩的坑。二是 channel 与 subtype 参数需要从设备能力集拿到不是凭感觉填。channel 从 1 开始subtype 为 0 时一般是主码流分辨率高、码率大适合录像和全屏预览为 1 时是子码流延迟和带宽占用更低适合多画面轮巡。拿到 URL 后先在 VLC 里验证一遍能出图就说明设备侧没问题再接代码这个习惯能省掉大量排查时间。很多项目里的“播放不出来”其实不是代码问题是 URL 本身就不对。3.2 ONVIF 发现设备不用挨个试 IP实际项目里摄像头 IP 经常不固定网段也不止一个。ONVIF 的 WS-Discovery 多播探测就是解决这个的向239.255.255.250:3702发一条 SOAP 探测报文网内支持 ONVIF 的设备会回 ProbeMatch。Demo 把它封装在 DeviceManager 的 discover 方法里流程如下// 伪代码省略 SOAP 报文细节 std::vectorDeviceInfo DeviceManager::discover() { sendProbe(); // 组播到 239.255.255.250:3702 auto responses recvProbeMatches(); // 收集设备响应 for (auto resp : responses) { DeviceInfo dev; dev.ip getXAddrs(resp); // 从响应里取设备地址 dev.name getDeviceName(resp); devList.push_back(dev); } return devList; }拿到设备地址后还要走 getProfiles通过 ONVIF 的 media 服务查询每个 profile 对应的 RTSP 地址。这一步返回的 StreamUri 通常是rtsp://ip:554/...形式可以直接交给拉流模块。ONVIF 的好处是跨厂商缺点是不同的设备对 Profile 实现有差异有的会在返回 URI 里携带 token 参数拼接时不要随手丢掉。3.3 厂商私有 SDK 接入 NVR通道列表与实时预览RTSP 方式适合单个 IPC。要对接 NVR 列通道、查录像、校验用户权限走厂商私有 SDK 更快。Demo 的 NvrSdkWrapper 对上提供极简接口class NvrSdkWrapper { public: bool login(const std::string ip, int port, const std::string user, const std::string pwd); std::vectorChannelInfo getChannels(); bool startRealPlay(int channel, PlayCallback cb); bool stopRealPlay(); void logout(); };内部流程对业务层不可见login 时初始化 SDK 环境、建立长连接、获取登录句柄getChannels 返回该 NVR 挂载的通道列表注意通道数组下标和 RTSP URL 里的 channel 未必相等返回的通道号要原样拿去拉流startRealPlay 走 SDK 的实时预览接口码流通过回调吐出来。SDK 的回调线程和 Demo 自己的解码线程之间同样要经过 FrameQueue。厂商 SDK 回调不能在 SDK 工作线程里做耗时操作更不能再做一次解码。我在封装层里让回调线程只做一次内存拷贝把数据塞进 AVPacket 环形缓冲由解码线程取走。这一步处理不好轻则回调阻塞重则 SDK 直接断线重连。选择 RTSP 还是 SDK判断标准很直接只是看画面RTSP 够了要控制云台、查录像回放、拿告警信息就必须上 SDK。注意私有 SDK 登录用户跟 ONVIF 用户往往是两套账密项目里经常在这里产生“设备配置没变但登录 401”的乌龙这个放到第五部分细说。4. 核心解码与显示FFmpeg 拉流上屏的完整链路这一章是整个 Demo 的技术核心。从打开 RTSP 流开始到画面出现在 QWidget 上中间每一步都有参数细节和容易翻车的点。4.1 打开 RTSP 流先设参数再开流FFmpeg 打开 RTSP 用一句 avformat_open_input 就能做但网络差的时候默认参数会导致长时间卡在“打开中”或者画面花成一片。Demo 在打开前用 AVDictionary 预置了三个关键参数AVDictionary* opts nullptr; av_dict_set(opts, rtsp_transport, tcp, 0); av_dict_set(opts, stimeout, 5000000, 0); // 微秒5 秒 av_dict_set(opts, max_delay, 500000, 0); // 微秒500ms AVFormatContext* fmtCtx avformat_alloc_context(); int ret avformat_open_input(fmtCtx, url.c_str(), nullptr, opts); if (ret 0) { // 用 av_strerror 转换成可读信息UI 层展示 } av_dict_free(opts);参数说明rtsp_transporttcp 是安防项目的默认选择。UDP 默认丢包后 H.264 画面会出现花屏马赛克TCP 虽然延迟稍高但画面稳定优先。stimeout 是底层 socket 读写超时单位微秒设 5 秒能避免摄像头离线时界面一直转圈不返回有些 SDK 文档写的是毫秒单位容易搞错。max_delay 控制内部缓冲上限设 500ms 足够应对局域网抖动又不会像默认值那样把延迟拖到不可接受。打开之后还要 avformat_find_stream_info 读流信息找到视频流索引。现在设备默认编码越来越往 H.265 走所以解码器一定按 codec_id 动态查找不要写死 H.264否则新设备直接黑屏。4.2 解码循环读取、发送、接收三步走FFmpeg 现代解码接口是 send/receive 模式职责分明AVPacket pkt; while (running_) { int ret av_read_frame(fmtCtx, pkt); if (ret 0) { // 读取超时或流中断走自动重连逻辑 break; } if (pkt.stream_index videoStreamIndex_) { if (avcodec_send_packet(codecCtx_, pkt) 0) { AVFrame* frame av_frame_alloc(); while (avcodec_receive_frame(codecCtx_, frame) 0) { frameQueue_-push(frame); frame av_frame_alloc(); // 帧被队列持有需分配新帧 } } } av_packet_unref(pkt); }解码线程的逻辑说明av_read_frame 拿到的是复用的 AVPacket用 pkt.stream_index 过滤掉音频流和其他通道avcodec_send_packet 把压缩数据交给解码器avcodec_receive_frame 循环取解码结果一次 send 可能解出多帧所以内层是 while。Demo 目前丢掉了音频因为预览场景只关心画面但结构上保留了音频流过滤逻辑后面想加声音只需把音频流也送进解码器在 UI 层用 QAudioOutput 播放即可。frame 处理完一帧后要重新分配因为 push 出去的帧由队列持有解码器内部不保留引用复用同一块 frame 会导致显示端画面错乱。当 av_read_frame 超时或出错时RtspStreamer 里的策略是先关闭当前 fmtCtx等 2 秒重新打开一次重连次数限制 3 次超过就对外发 streamOffline 信号让 UI 提示“设备已离线”。不要无限循环重连否则网线拔掉时线程会一直空转。4.3 帧格式转换YUV 到 QImage 的关键一步解码出来的 AVFrame 是 YUV420PQImage 不认 YUV需要 swscale 转成 RGB32。这一步容易忽略对齐问题导致图像越来越斜SwsContext* sws sws_getContext( srcW, srcH, AV_PIX_FMT_YUV420P, srcW, srcH, AV_PIX_FMT_RGB32, SWS_FAST_BILINEAR, nullptr, nullptr, nullptr); uint8_t* dstData[4] {nullptr, nullptr, nullptr, nullptr}; int dstLinesize[4] {0, 0, 0, 0}; av_image_alloc(dstData, dstLinesize, srcW, srcH, AV_PIX_FMT_RGB32, 32); sws_scale(sws, frame-data, frame-linesize, 0, srcH, dstData, dstLinesize); QImage img(dstData[0], srcW, srcH, dstLinesize[0], QImage::Format_RGB32);av_image_alloc 分配输出缓冲区时linesize 会按 32 字节对齐QImage 构造函数里第三个参数必须传对齐后的行宽不能想当然写 srcW*4。Format_RGB32 在 Qt 中实际是 0xFFRRGGBB 的排列在 x86 小端机器上内存对应 BGRA与 FFmpeg 输出顺序正好匹配所以选 RGB32 而不是 RGB888。如果发现画面发灰多半是 color range 全范围与视频范围差异可以通过 frame 的 color_range 字段配合 sws_setColorspaceDetails 补偿默认场景先不处理也能看。4.4 抓图与本地录像播放之外的常用扩展Demo 界面提供抓图和录制两个按钮。抓图实现直观把 UI 线程最近一次拿到的 QImage 直接 save 成 PNG 文件。录像稍微复杂涉及把 AVPacket 的 H.264 裸流写进 MP4 容器。Demo 在拉流线程按需旁路不重新编码直接把原始 AVPacket 写入 avformat 封装的 mp4 muxer不额外消耗 CPU。写入前要记得给视频流加 extradata也就是 SPS/PPS 信息从解码器上下文的 extradata 字段取再用 avformat_write_header 开头、av_write_trailer 结尾。很多人录出来的文件打不开往往就是漏了 extradata。这一层逻辑复用性很好后面做回放录像导出时可以直接搬。5. 常见问题与排查播放 Demo 的黑屏、花屏、卡顿实录这套 Demo 实际调试中遇到的问题一半来自设备协议盲区一半来自多线程时序。下面几条按出现频率排列每一条都对应可以直接抄的修改点。5.1 双击通道黑屏日志报 401 Unauthorized现象设备在 VLC 里能打开代码里 avformat_open_input 却返回 401界面提示认证失败。原因RTSP URL 里的用户名或密码含特殊字符最常见是密码里有、空格、#拼出来的 URL 语义被改变或者设备开了 digest 鉴权而代码默认用了 basic还有一种可能ONVIF 配置的是媒体用户Web 管理页用的是管理员用户两套账密不一样。解决统一走 3.1 节里的 urlEncode 再拼 URLRTSP 鉴权方式让 FFmpeg 自动协商不要强行指定 basic。更省心的做法是在设备 Web 管理页单独建一个 RTSP 专用用户权限只开预览和回放不把管理员口令交到业务代码里。NVR 场景尤其容易踩因为 NVR 通道列表里每个 IPC 又有自己的账密和 NVR 登录用户混在一起日志提示就变得很模糊。5.2 画面花屏马赛克几分钟跳一次帧现象主码流播放几十秒后出现花屏画面像撕裂一样过一会儿又恢复没法交付。原因默认 rtsp_transport 是 UDP。局域网看似稳定实际丢包率不为零H.264 一个 NAL 包丢了会直接影响后续参考帧。画面自动恢复是因为关键帧到了但体验已经不能接受。解决把 rtsp_transport 强设成 tcpUDP 花屏立刻消失代价是延迟略有增加局域网预览场景可忽略。如果必须用 UDP就得做应用层丢包重传或者接受 GOP 内花屏的物理限制。5.3 延迟越拖越大画面停在一两秒前现象刚打开延迟还好播放半小时后画面明显滞后实时性不“实时”。原因max_delay 设得过大拉流线程又是纯阻塞读网络抖动时缓冲区持续堆积队列一直处于满状态。另一个叠加因素是 B 帧的重排序缓冲默认行为会把多帧留在解码器里等待未来帧。解决把 stimeout 和 max_delay 调小到业务接受的值比如 max_delay 设 500ms打开流时额外设置fflagsdiscardcorrupt减少对坏包的缓冲依赖。更激进的做法是在解码线程做帧抽取直接从 AVFrame 里取最新帧丢弃队列里的老帧。延迟控制在 300ms 内一般就够用。5.4 关闭视频时程序崩溃错误位置在 FrameQueue现象每次停止播放或切换码流时程序偶尔崩溃报错栈指向 FrameQueue 的 push 或 pop换个电脑概率还不一样典型的玄学崩溃。原因队列里存的是 AVFrame 指针指针对应的 AVFrame 可能在解码线程里已被 av_frame_freeUI 线程再 pop 出来使用就悬垂了。另外切换码流时旧解码线程还没退出就开始释放队列两个线程同时操作同一把锁看着有锁实际顺序不对。解决严格按“停拉流线程、停解码线程、再清空队列”的顺序退出切换码流时也走同一套流程在停止时调用 FrameQueue 的 clear 方法释放残留帧。AVFrame 的引用计数不要自己 new/delete全部走 av_frame_alloc、av_frame_ref、av_frame_free 三件套。这个 bug 我调了两天才定位最后用 ASAN 跑一次就露馅。5.5 NVR 通道号对不上拉了别人的画面现象双击第二个通道画面上显示的却是第一个通道的摄像头画面。原因私有 SDK 返回的通道编号和 RTSP URL 里 channel 参数不是同一个值。部分设备从 0 开始部分从 1 开始更隐蔽的是通道顺序与设备物理连接顺序未必一致。解决不要自己算通道号用 getChannels 返回的 ChannelInfo 里自带的编号直接透传给拉流模块。如果走 RTSP逐通道对比视频画面建立一张 IP 与通道号的映射表存进 config.ini二次开发时换成数据库就是完整的设备管理功能。这个问题查起来快但第一次遇到时会怀疑人生。6. 二次开发改造从预览到回放、再到 Web 播放落地6.1 给 Demo 加按时间回放在现有登录和通道列表基础上加回放工作量集中在 SDK 封装层。先从设备查询某通道的录像时间段拿到时间轴后把 startRealPlay 换成播放回放流的接口传入通道号、开始时间和结束时间。回放流的解码路径和实时预览完全一致都是拿到 AVPacket 再送 FFmpeg所以界面和队列一行都不用大改。我一般建议把实时播放和回放抽象成同一个 Player 接口否则后面加对讲、加告警截图时每种流都要改一遍解码层。6.2 把桌面 Demo 改成 Web 播放网页播放摄像头画面是常见集成诉求但浏览器不支持 RTSP。常见做法是后端拉流转 HLS 或转 WebRTC。HLS 实现最快从 RtspStreamer 拿 AVPacket复用 4.4 的转封装代码把输出改成 hls muxer生成 m3u8 切片放到静态目录前端用 hls.js 播放。代价是切片必然带来 3 到 10 秒延迟做监控大屏可以接受做人脸对讲不行。要低延迟就上 WebRTC需要引入流媒体服务器把解码后的帧重新编码推上去改动量明显大。6.3 性能验证和优化顺序改造完成后的验证我习惯按三步走先看 CPU 占用软解 1080p 主码流时单路不应超过一个完整核心再看队列深度是否经常打满 30打满说明解码能力跟不上优先考虑换硬解最后连续切换通道操作 10 分钟不崩溃、不积累延迟才算过。硬解在 Windows 上推荐 d3d11vaLinux 用 vaapi接口变动不大主要影响在像素格式对齐那一步。这个 Demo 从写第一个 RTSP 地址到最终可用用了大约两个迭代周期。从那以后我每次接到新的设备接入任务都会先把 RTSP 地址拿到 VLC 里验证一遍再动代码。这个习惯帮我避开了至少一半的所谓“代码 bug”。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑