简介:一套面向大华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 变成 AVFrame,UI 线程把 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_guard<std::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_guard<std::mutex> lock(mtx_); if (q_.empty()) return nullptr; AVFrame *f = q_.front(); q_.pop(); return f; } void clear() { std::lock_guard<std::mutex> lock(mtx_); while (!q_.empty()) { av_frame_free(&q_.front()); q_.pop(); } } private: int maxSize_; std::queue<AVFrame*> q_; std::mutex mtx_; };逻辑说明:push 时如果队列满,先释放最旧一帧,再把新帧做一次引用计数持有后入队;pop 把队首帧指针交给调用方,调用方负责用完释放。这里有个使用前提——AVFrame 不能整体拷贝,必须走 av_frame_ref,否则解码线程里复用缓冲区会导致画面花甚至崩溃。clear 方法专供停止播放时清空残留,解决了后面避坑章节里那个“关闭视频崩溃”的问题。maxSize 按 30 帧设计,大约一秒多一点的数据量,既能缓冲网络抖动,又不至于把延迟拖太久。
3. 设备接入:RTSP 拉流与 ONVIF 发现的细节
接入层解决两件事:设备在哪,地址是什么。一个局域网的项目里,设备可能分布在多个网段,账号密码也各式各样,这一层做扎实了,后面的播放都顺。
3.1 RTSP URL 的拼装:换设备厂最先看这里
RTSP 地址是一切开端。各厂商规则差异较大,但普遍支持带参格式:rtsp://user:pass@ip:port/streamPath?channel=x&subtype=y。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::vector<DeviceInfo> 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::vector<ChannelInfo> 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_transport=tcp 是安防项目的默认选择。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 是 YUV420P,QImage 不认 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 再拼 URL;RTSP 鉴权方式让 FFmpeg 自动协商,不要强行指定 basic。更省心的做法是在设备 Web 管理页单独建一个 RTSP 专用用户,权限只开预览和回放,不把管理员口令交到业务代码里。NVR 场景尤其容易踩,因为 NVR 通道列表里每个 IPC 又有自己的账密,和 NVR 登录用户混在一起,日志提示就变得很模糊。
5.2 画面花屏马赛克,几分钟跳一次帧
现象:主码流播放几十秒后出现花屏,画面像撕裂一样,过一会儿又恢复,没法交付。
原因:默认 rtsp_transport 是 UDP。局域网看似稳定,实际丢包率不为零,H.264 一个 NAL 包丢了会直接影响后续参考帧。画面自动恢复是因为关键帧到了,但体验已经不能接受。
解决:把 rtsp_transport 强设成 tcp,UDP 花屏立刻消失,代价是延迟略有增加,局域网预览场景可忽略。如果必须用 UDP,就得做应用层丢包重传,或者接受 GOP 内花屏的物理限制。
5.3 延迟越拖越大,画面停在一两秒前
现象:刚打开延迟还好,播放半小时后画面明显滞后,实时性不“实时”。
原因:max_delay 设得过大,拉流线程又是纯阻塞读,网络抖动时缓冲区持续堆积,队列一直处于满状态。另一个叠加因素是 B 帧的重排序缓冲,默认行为会把多帧留在解码器里等待未来帧。
解决:把 stimeout 和 max_delay 调小到业务接受的值,比如 max_delay 设 500ms;打开流时额外设置fflags=discardcorrupt,减少对坏包的缓冲依赖。更激进的做法是在解码线程做帧抽取,直接从 AVFrame 里取最新帧,丢弃队列里的老帧。延迟控制在 300ms 内一般就够用。
5.4 关闭视频时程序崩溃,错误位置在 FrameQueue
现象:每次停止播放或切换码流时程序偶尔崩溃,报错栈指向 FrameQueue 的 push 或 pop,换个电脑概率还不一样,典型的玄学崩溃。
原因:队列里存的是 AVFrame 指针,指针对应的 AVFrame 可能在解码线程里已被 av_frame_free,UI 线程再 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 上推荐 d3d11va,Linux 用 vaapi,接口变动不大,主要影响在像素格式对齐那一步。
这个 Demo 从写第一个 RTSP 地址到最终可用,用了大约两个迭代周期。从那以后我每次接到新的设备接入任务,都会先把 RTSP 地址拿到 VLC 里验证一遍,再动代码。这个习惯帮我避开了至少一半的所谓“代码 bug”。希望帮到你。
本文还有配套的精品资源,点击获取