简介:面向有C++与Qt基础、希望通过FFmpeg实战掌握音视频采集存储的开发者,这份源码演示了从摄像头RTSP流采集画面、实时显示到界面,并最终保存为.avi文件的完整流程。核心转换链路为rtsp→yuv→h264→avi,覆盖视频流拉取、像素格式转换、编码与封装等关键环节,涉及FFmpeg解码、Qt界面刷新与文件输出等常用操作。资源包共21个文件,以C++源码为主,包含6个.h头文件与6个.cpp实现文件,分别承担界面声明与业务逻辑;另有ui、qrc、ico及sln、rc等配置与资源文件,分别用于界面设计、资源管理和工程构建。整体压缩包仅26KB,结构精简,便于快速查阅。已有1187人学习下载。通过阅读工程源码,可直观了解Qt与FFmpeg的集成方式、多线程采集思路及视频存储参数配置,适合希望上手实战摄像头应用的开发者参考。
1. 摄像头视频采集与存储,为什么绕不开 FFmpeg 和 Qt 这套组合
桌面端要做的往往不是调起摄像头聊个天,而是在自己的程序里把设备画面拉起来、编码、落盘成可回放的视频文件,用于巡检记录、设备验证、离线监测这类场景。技术上方案不少,Qt Multimedia 自带 QCamera,OpenCV 也能一把抓帧,但真到生产环境就露馅了:格式协商受限、H.264 编码器缺失、MP4 封装不完整,录到一半断电整个文件报废。最后大家都会绕回 FFmpeg。标题里的这个组合,本质是把 FFmpeg 的采集、编码、封装能力和 Qt 的界面、线程、文件管理接起来,构成一套可跨平台部署的桌面采集存储系统,适合要在 Qt 工程里自己掌控采集管线的开发者。
2. 选型:FFmpeg 管采集编码链路,Qt 管界面与调度
2.1 一条视频从镜头到磁盘,FFmpeg 和 Qt 的职责边界
USB 摄像头画面最终变成 MP4 文件,中间经过“设备枚举 → 打开设备 → 取流 → 解码 → 像素格式转换 → 预览显示 → 编码 → 封装 → 写文件”九个环节。分工上有一条默认的线:凡是跟音视频字节打交道的,全部走 FFmpeg;凡是跟窗口、事件循环、文件命名、定时策略相关的,全部走 Qt。
| 环节 | 实现方 | 说明 |
|---|---|---|
| 设备枚举与打开 | FFmpeg libavdevice | 同一套 API 兼容 Windows dshow / Linux v4l2 / macOS avfoundation |
| 解码原始帧 | FFmpeg libavcodec | MJPG、H.264 等压缩格式先解成 YUV 原始帧 |
| 像素格式转换 | FFmpeg libswscale | YUV420P / YUYV 转 RGB24 供 QImage 显示 |
| 实时预览 | Qt QLabel + QImage | 与窗口系统、键盘事件天然融合 |
| 编码与封装 | FFmpeg libavcodec / libavformat | libx264 编码,MP4 封装 |
| 线程与分段策略 | Qt QThread / QTimer | 跨平台线程模型,定时触发分段写文件 |
Qt Multimedia 的 QMediaCaptureSession 在 Windows 上对 UVC 摄像头支持比较粗糙,想手动指定 YUYV 还是 MJPG、想控制 GOP 大小,几乎做不到。OpenCV 的 VideoWriter 更尴尬,它能把帧写进 AVI,但写 MP4 时对 moov 位置完全失控,录到一半断电文件就打不开,而且跨平台编码器支持稀碎。FFmpeg 是这几条路径里唯一能同时控制采集参数、编码参数和封装细节的。
2.2 接入 Qt 工程:FFmpeg 的编译选型与初始化
Windows 上优先下载官方编译好的 shared build,解压后把 include 和 lib 路径加进工程;Linux 直接通过 apt 装 libavformat-dev、libavcodec-dev、libswscale-dev、libavdevice-dev。这里最关键的坑是 build 本身有没有带 libx264,拿一个不带 GPL 组件的版本,后面编码只能退回 mpeg4,文件体积直接翻几倍。验证方式很简单:
ffmpeg -hide_banner -encoders | grep 264有输出说明该 build 支持 H.264 编码。qmake 工程里这样接:
INCLUDEPATH += D:/ffmpeg/include LIBS += -LD:/ffmpeg/lib -lavformat -lavcodec -lavutil -lswscale -lavdeviceCMake 工程则建议用 pkg-config 方式:
find_package(PkgConfig REQUIRED) pkg_check_modules(AV REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil libswscale libavdevice) target_link_libraries(your_app PRIVATE PkgConfig::AV)提示:FFmpeg 5.0 之后已经不需要调用
av_register_all(),解复用器、编码器按需自动注册。真正容易漏的是avdevice_register_all(),不调用它,Windows 上 av_find_input_format("dshow") 会直接返回空指针。建议在 main() 里带上这行,并把 av_log_set_level 设为 AV_LOG_INFO,排错时改成 AV_LOG_DEBUG。
日志输出默认走 stderr,在 Qt 界面程序里会跟 qDebug 输出混在一起。常见的做法是用av_log_set_callback()重定向到自己的槽函数,把 FFmpeg 的日志按级别转发给 Qt 的日志系统。初始化代码集中在构造阶段执行,避免随后在采集线程里重复调用。
2.3 编译选项里容易忽略的许可问题
带 libx264 的 FFmpeg 编译结果受 GPL 约束,如果产品要闭源分发,要么用--enable-gpl --enable-libx264后按 GPL 开源应用代码,要么换成更宽松的编码器(如 Intel 的 QSV H.264 或系统自带的 MediaFoundation)。这个决策影响的是后续分发形式,不是技术路线,但越早定越好,省得录出来的成品文件格式又要推翻重来。
3. 打开摄像头:设备枚举、参数协商与 Qt 实时预览
3.1 Windows 用 dshow,Linux 用 v4l2:设备枚举的两种姿势
开发时查看机器上有哪些可用摄像头,最直接的是用 ffmpeg 命令行:
# Windows 列出 DirectShow 视频设备 ffmpeg -hide_banner -sources dshow # Linux 列出 v4l2 设备 ffmpeg -hide_banner -sources v4l2Windows 输出会列出"Integrated Camera"这样的设备名字,这个字符串要原样传给 avformat_open_input。但注意设备名中间可能有空格,命令解析时不要按空格拆字段。程序内部枚举设备有两个思路:一是调用系统 API(Windows 的 SetupAPI、Linux 的 /dev/videoX 遍历)拿到设备路径;二是直接起一个子进程执行上述命令再解析输出。我一般用第二种,省去 COM 初始化的麻烦,代价是首次枚举有 1~2 秒延迟,放进界面线程会卡,放后台线程里做就行。
设备名拿到后转成 FFmpeg 的输入格式:
AVFormatContext *fmtCtx = nullptr; AVInputFormat *inputFmt = av_find_input_format("dshow"); // Linux 换 "v4l2" const char *deviceName = "Integrated Camera"; int ret = avformat_open_input(&fmtCtx, deviceName, inputFmt, nullptr); if (ret < 0) { char errBuf[AV_ERROR_MAX_STRING_SIZE] = {0}; av_strerror(ret, errBuf, sizeof(errBuf)); qCritical() << "打开摄像头失败:" << errBuf; // EIO=设备被占用, ENOMEDIUM=无帧信号 }注意 av_find_input_format("dshow") 只认小写,写成 "DShow" 返回空。Linux 上换成 "v4l2" 后设备路径传 /dev/video0 即可。如果同时接了 USB 摄像头和 HDMI 采集卡,v4l2 的设备编号不固定,建议遍历 /dev/video* 并用v4l2-ctl --list-devices对照名称,避免写死。
3.2 格式协商:先谈好分辨率、帧率和像素格式再开流
摄像头输出的原始格式基本是 MJPG 或 YUYV 两种。YUYV 是未压缩的 YUV 4:2:2,1080p@30 的带宽需求大约 1.5 Gbps,USB 2.0 只有 480 Mbps 根本跑不动,所以大多数摄像头默认出 MJPG——摄像头固件内部压缩过的 JPEG 帧,USB 带宽占用低,代价是解码吃 CPU。这直接影响后续处理器的选型和线程模型的设计:MJPG 源必须加解码步骤,解码在哪个线程做、解码后的帧给谁消费,得提前定好。
打开设备时用 AVDictionary 传参,在 avformat_open_input 内部直接由驱动生效:
AVDictionary *opts = nullptr; av_dict_set(&opts, "video_size", "1280x720", 0); av_dict_set(&opts, "framerate", "30", 0); // 部分 UVC 驱动支持切换输出格式,不支持的会忽略此参数 av_dict_set(&opts, "pixel_format", "mjpeg", 0); ret = avformat_open_input(&fmtCtx, deviceName, inputFmt, &opts); av_dict_free(&opts);video_size 和 framerate 是硬参数,摄像头驱动不支持时会协商失败。协商失败后不要直接放弃,改成用 avformat 探测到的默认参数重开一次,很多摄像头对 1280x720@60 不支持但支持 1280x720@30。pixel_format 只对部分驱动有效,开启后可以在解复用器侧拿到带AV_PIX_FMT_MJPEG或AV_PIX_FMT_YUYV422的流,后续解码和显示逻辑要按实际格式分支处理。
打开后通过fmtCtx->streams[0]->codecpar读取实际生效的格式:
| 字段 | 含义 | 常见值 |
|---|---|---|
| width / height | 实际分辨率 | 1280x720、1920x1080 |
| format | 像素格式 | AV_PIX_FMT_YUYV422、AV_PIX_FMT_MJPEG |
| framerate | 实际帧率 | 30、60 |
这个参数表决定后面 sws_getContext 的源格式,写错一步画面就会花屏或者颜色整体偏绿偏紫。比如 MJPG 源解出来的帧一般是 YUVJ420P(full range),而 YUYV 源解出来是 YUV422P,两者要分别处理。
3.3 取帧→解码→显示:一个能被复制进工程的最小循环
采集线程循环里最核心的逻辑是 av_read_frame 拿包、avcodec_send_packet / avcodec_receive_frame 解码、sws_scale 转 RGB24、再拷贝进 QImage。完整代码:
while (!m_stopRequested) { AVPacket *pkt = av_packet_alloc(); int ret = av_read_frame(fmtCtx, pkt); if (ret < 0) { av_packet_free(&pkt); // AVERROR(EAGAIN) 表示非阻塞模式暂无数据,不应当做错误退出 if (ret != AVERROR(EAGAIN)) break; continue; } if (pkt->stream_index == videoStreamIdx) { ret = avcodec_send_packet(decCtx, pkt); if (ret >= 0) { while (avcodec_receive_frame(decCtx, frame) == 0) { // 格式转换:源格式以解码器实际输出为准 sws_scale(swsCtx, frame->data, frame->linesize, 0, frame->height, rgbFrame->data, rgbFrame->linesize); QImage img(rgbFrame->data[0], width, height, rgbFrame->linesize[0], QImage::Format_RGB888); emit frameReady(img.copy()); // 深拷贝,跨线程安全 } } } av_frame_unref(frame); av_packet_free(&pkt); }这段代码有几个点必须说明。img.copy()不能省,因为 QImage 构造时只引用了 rgbFrame 的内存指针,下一次循环 sws_scale 就会覆盖这块缓冲,不深拷贝的话 UI 线程显示的图像会被撕裂成花屏甚至崩溃。AVERROR(EAGAIN)是 FFmpeg 在非阻塞模式下取不到新帧时返回的特殊码,不是设备错误,很多新手把它当失败退出,结果摄像头一卡就断流。stream_index == videoStreamIdx的过滤在设备只有视频流时不是必须的,但加了以后摄像头内置麦克风的音频流不会干扰主逻辑,为后续加录音留了位置。
3.4 停止采集线程:别在阻塞的 av_read_frame 上直接 join
av_read_frame 默认是阻塞的,如果 UI 线程点“停止”后直接等待采集线程结束,而采集线程还卡在 av_read_frame 里读下一帧,程序会看起来像没反应。常见的做法是先在 UI 线程调用avformat_close_input(&fmtCtx),这个函数会中断正在进行的阻塞读操作,让采集线程从 av_read_frame 里带着错误码返回。注意释放顺序:先关输入上下文,再等线程 join,最后释放解码器和 sws 上下文。反过来操作会死锁。
4. 存储系统:H.264 编码、MP4 封装与分段写文件
4.1 为什么选 H.264,编码参数怎么定
巡检记录类视频要的不是极致画质,而是“能看清 + 文件小 + 播放器都认”。H.264 是当前兼容性底线,libx264 编码器的控制参数集中在 preset 和 crf 两个值上:preset 控制编码速度和压缩率,veryfast 比 slow 编码速度快几倍但文件体积大 30%~50%;crf 控制质量,数值越低质量越高文件越大,23 是 x264 官方默认值。
AVCodec *encoder = avcodec_find_encoder_by_name("libx264"); AVCodecContext *encCtx = avcodec_alloc_context3(encoder); encCtx->width = 1280; encCtx->height = 720; encCtx->time_base = (AVRational){1, 30}; encCtx->framerate = (AVRational){30, 1}; encCtx->pix_fmt = AV_PIX_FMT_YUV420P; encCtx->gop_size = 30; // 每 30 帧一个关键帧,即 1 秒 1 个 I 帧 AVDictionary *encOpts = nullptr; av_dict_set(&encOpts, "preset", "veryfast", 0); av_dict_set(&encOpts, "crf", "23", 0); av_dict_set(&encOpts, "tune", "zerolatency", 0); int ret = avcodec_open2(encCtx, encoder, &encOpts);参数说明:gop_size 单位是“帧”不是“秒”,设定 30 意味着每隔 30 帧强制出一个关键帧(I 帧)。I 帧体积是 P 帧的 5~10 倍,gop 太小文件膨胀明显,gop 太大会导致 seek 定位不精准。tune=zerolatency 关闭 x264 的 lookahead 缓冲,这对本地录制不是必需的,但如果你同时做预览回显,它能显著降低画面延迟。注意编码器要求的输入格式是 YUV420P,而摄像头解出来的是 YUVJ420P 或 YUYV,直接用会报pixel format not supported,需要再走一次 sws_scale 把预览用的 RGB24 帧转成 YUV420P 喂给编码器。
4.2 时间戳推导:pts 不重写,录出来的视频必出问题
摄像头采集包自带的 time_base 和编码器、封装器各不相同,直接沿用会把不同时基的数值混在一起写进容器,播放器会出现画面跳帧、进度条异常甚至完全无法播放。标准做法是用墙钟时间做统一基准,每次取帧时换算一次:
int64_t nowUs = av_gettime(); // 微秒级绝对时间 AVRational dstTb = encCtx->time_base; int64_t ptsUs = nowUs - startUs; // 相对录制开始的偏移 pkt->pts = av_rescale_q(ptsUs, (AVRational){1, 1000000}, dstTb); pkt->dts = pkt->pts; pkt->duration = av_rescale_q(1, (AVRational){1, 30}, dstTb);av_rescale_q 内部做的是高精度有理数换算,带四舍五入修正,不要用浮点乘法替代,帧率稍高就会积累出每秒几十微秒的误差,录一小时偏差大到肉眼可见。这里让 dts 直接等于 pts,本质是放弃 B 帧重排,代价是压缩率略微下降,但换来的是时间戳单调性可验证,适合编码陈旧的播放器。还要用一个变量记录上一次写入的 pts,新帧的 pts 必须严格大于它,发现倒退就把当前帧丢弃——dshow 设备在系统睡眠唤醒后经常产生时间戳跳变,这个检查是录制成败的关键。
4.3 分段写文件:单文件超过 4GB 之前自动切割
长时间录制如果只写一个文件,会有三个隐患:文件系统 FAT32 单文件 4GB 上限、单个文件损坏全盘皆输、按时间检索不方便。所以工程上普遍采用分段录制策略,常见参数如下:
| 分段时长 | 适用场景 | 单文件大小估算(720p@30, crf23) |
|---|---|---|
| 15 秒 | 运动场景、需要快速上传 | 约 2~4 MB |
| 30 秒 | 一般巡检 | 约 4~8 MB |
| 60 秒 | 长时间无人值守 | 约 8~16 MB |
实现方式是在写帧循环的外层套一个计数器或定时器,到点调用切段函数。切段的核心是把当前输出上下文写尾并关闭,再按新文件名建一个新的:
void SegmentWriter::rotateFile() { // 关闭当前文件 av_write_trailer(m_outCtx); avio_closep(&m_outCtx->pb); avformat_free_context(m_outCtx); // 生成新文件名并重新初始化输出上下文 QString fileName = QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".mp4"; createOutputFile(fileName); }注意一个容易踩的坑:在编码器没有收到流结束信号时直接 av_write_trailer,MP4 的 moov atom(索引信息)会缺失一部分,导致最后几帧丢帧或者播放器认为时长变短。所以切段前必须向编码器发送空包(avcodec_send_packet(codecCtx, nullptr))来刷出编码器内部缓冲的剩余帧,再把这些帧全部送入封装器,然后才允许调用 av_write_trailer。完整的切段顺序是:flush 编码器 → 写尾 → 关文件 → 建新文件 → 重新写头。
4.4 文件命名与归档策略
分段文件用时间戳命名是最可靠的做法。命名格式建议用yyyyMMdd_hhmmss而不是yyyy-MM-dd-hh-mm-ss,后者在 Windows 资源管理器里排序时会把-当成分隔符,而前者可以按字典序直接当作时间线排序。如果一秒钟内可能切多段,在末尾追加一个三位序号:20250411_153022_001.mp4。归档目录按日期分组,records/2025-04-11/,这样清理旧数据时按目录删除,不用遍历文件名解析时间。
5. 录制文件的验证手法与断电损坏防护
5.1 用 ffprobe 验证每个分段是否完整
录制完成后不能假设文件一定可播,用 ffprobe 快速检查每段的时长、编码器和像素格式是否符合预期:
ffprobe -v error \ -show_entries format=duration,size \ -show_entries stream=codec_name,width,height,avg_frame_rate \ -of json record_20250411_153022_001.mp4正常输出里 duration 应该接近 30.0 秒,codec_name 是 h264,width/height 是 1280x720。常见异常:ffprobe 报moov atom not found,说明这个文件没有正常写完,通常是进程被 kill 或断电导致 av_write_trailer 没执行;duration 远小于预期,说明采集过程中出现丢帧。
5.2 断电损坏防护:先写临时文件,录完再改名
这是嵌入式巡检设备最容易忽略的细节。MP4 文件在录制过程中 index(moov)一直在文件尾部累积,如果录制到一半断电,不仅刚录的部分丢失,前面已经写好的数据也会因为 moov 缺失而整个不可用。
常见的做法是分段录制时先写.tmp后缀文件,等这一分段正常完成、av_write_trailer 返回后,再把文件名从.tmp改回.mp4:
QString tmpName = QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".tmp"; // ... 录制循环写 tmpName ... av_write_trailer(ctx); QFile::rename(tmpName, tmpName.replace(".tmp", ".mp4"));这样任何暴力断电时刻磁盘上只存在一个不完整的.tmp文件,它不会干扰播放器扫描.mp4文件目录,下次开机通过清理逻辑删掉即可。“录完才改后缀”这个策略,比录制完成后用 ffmpeg -movflags +faststart 二次处理要可靠,后者存在一段“原文件已损坏、新文件还没生成”的尴尬窗口。
5.3 播放兼容性排错:先确认封装和编码,再怀疑解码器
如果录制出的 mp4 在 Windows Media Player 里无法播放,优先做两层检查:ffprobe -show_streams确认 codec_name 是 h264 而不是 mpeg4;再确认容器格式,ffprobe -show_format看 format_name 是否为 mov,mp4,m4a,3gp,3g2,mj2。如果 format_name 里混入了其他东西,说明输出封装器选错了,创建输出上下文时avformat_alloc_output_context2(&outCtx, nullptr, "mp4", filename)的第二个参数要显式传 "mp4"。黑屏但声音正常、颜色整体偏绿,则是像素范围不匹配,编码器统一走 AV_PIX_FMT_YUV420P 就能解决。
5.4 最后一招:把 FFmpeg 命令行工具当作调试器用
采集存储模块联调期间,不要只依赖程序内日志。命令行工具和项目内 FFmpeg 共用同一套底层库,能用命令复现的问题就不用在 Qt 里打断点:
# 单独测试摄像头打开是否正常 ffmpeg -hide_banner -f dshow -i "Integrated Camera" -t 5 test.mp4 # 测试编码和封装是否正常 ffmpeg -re -i test_orig.mp4 -c:v libx264 -preset veryfast -crf 23 -f mp4 out.mp4如果在命令行下都失败,问题基本在驱动或 FFmpeg build 本身;如果命令行成功但 Qt 程序失败,问题在自己的调用顺序和参数传递上,优先对比av_dict_set的参数和命令行里写的是否一致。调试结束后记得把 av_log_set_level 调回 AV_LOG_WARNING,否则 FFmpeg 的生产日志会把 Qt 的调试输出淹没。
本文还有配套的精品资源,点击获取