简介:FFmpeg与Qt结合的摄像头视频采集录制工程,面向具备C/C++基础、希望深入学习音视频处理的开发者。工程演示了从USB摄像头获取1080p原始画面,在Qt界面中实时显示,并通过FFmpeg同步编码封装为mp4文件的完整流程,覆盖设备初始化、分辨率设定、编解码器选择、多线程采集与渲染等关键环节。压缩包共182个文件,大小约487KB,以头文件、静态库文件为主,辅以源文件、工程配置文件与界面文件,便于在Qt环境中直接导入学习。已有844人浏览学习,代码结构精炼,适合作为入门及实战参考。通过研究代码,可掌握FFmpeg常用接口调用顺序、Qt与视频流的衔接方式,也能理解高分辨率实时显示录制中的性能优化与排错思路;工程内含摄像头采集线程、界面交互逻辑与编码封装实现,方便快速改造为远程监控、视频会议或在线教育等应用的功能原型。
1. 用 FFmpeg + Qt 做摄像头采集:为什么这个组合值得试
FFmpeg 和 Qt 这对组合,做本机 USB 摄像头实时显示与同步录制,是我在实际项目里验证过最顺手的路线。原因很简单:Qt 自带的多媒体模块在 1080p 采集上有明显短板,延迟不稳定,能拿到的帧数据也被封装了一层,想再接编码器做录制就很别扭;而 FFmpeg 把设备枚举、参数设置、解码、滤镜、编码和封装全部统一成一条链路,配合 Qt 的界面能力,刚好互补。这套方案特别适合那些要做桌面录制工具、安防监控客户端、设备调试助手,或者任何需要“预览 + 落盘”的跨平台采集应用的开发者。这篇笔记会把完整流程拆开,直接给你可复现的步骤、参数和踩坑记录。
2. 方案选型与工程接入:FFmpeg 库编译、Qt 项目配置和线程模型
2.1 为什么不用 QCamera 或 OpenCV VideoCapture
很多项目一开始用的是 Qt 自带的 QCamera。它写起来确实快,几行代码就能把摄像头画面投到 QVideoWidget 上,但到了生产环境问题就出来了。一是延迟,QCamera 走的是 Qt Multimedia 的抽象层,在高分辨率下的缓冲和渲染路径比较重,实测在普通 USB 摄像头上做到 1080p@30fps 的流畅预览很勉强。二是拿不到原始帧,QCamera 的 QVideoFrame 拿到的数据已经被拷贝和处理过,你要直接接一个 H.264 编码器就得再绕一步,而且不同平台的行为差异还不小。
OpenCV 的 VideoCapture 是另一个常见选择。它做预览和简单的图片处理都非常高效,但它的定位是“图像处理”,不是“媒体流处理”。VideoCapture 拿到的 Mat 去喂给编码器没问题,可你仍然要自己管理编码器、容器和同步逻辑,而且 OpenCV 的 VideoWriter 在封装 MP4 时支持的编码格式有限,很多项目是在这里被卡住的。
FFmpeg 的优势在于整个链路是完整的:avdevice 负责打开本机摄像头,avcodec 负责解码和编码,avformat 负责封装 MP4,avfilter 能做缩放和格式转换。你不需要把采样、转换、编码拆成三套不同 API 来调,出错时排查链路也只需要盯一套日志。所以最终方案定为 Qt 做界面层,FFmpeg 做媒体层,窗口用 QWidget/QML 显示 QImage,录制端用 libx264 编码成 H.264 再封进 MP4。
2.2 FFmpeg 开发库的编译与 Qt 工程链接
如果你在 Windows 下做开发,最稳妥的方式是自己编译一套 FFmpeg SDK,或者找一个公认可靠的预编译制品。自己编译的好处是可以精准控制开关项,去掉用不到的模块,减小体积,还可以把 x264 这类外部编码器静态编进去。我一般这样配置:
./configure --enable-gpl \ --enable-version3 \ --enable-libx264 \ --enable-avdevice \ --enable-avcodec \ --enable-avformat \ --enable-avutil \ --enable-swscale \ --enable-swresample \ --enable-postproc \ --disable-debug \ --enable-shared make -j8这里重点解释几个开关。--enable-libx264是录制 MP4 的关键,FFmpeg 自带的 mpeg4 编码器虽然也能写进 MP4,但兼容性和压缩比都不如 x264。--enable-avdevice必须开,否则 avdevice_register_all 之后找不到 dshow 或 v4l2 等本机设备模块。--enable-shared在 Windows 上会生成 dll,调试时更新方便,但部署时要带着一起走;如果是嵌入式或单文件分发,改成--disable-shared --enable-static会更省事。
Qt 工程接入时,如果用的是 qmake,在 .pro 文件里这样写:
INCLUDEPATH += $$PWD/ffmpeg/include LIBS += -L$$PWD/ffmpeg/lib \ -lavdevice \ -lavformat \ -lavcodec \ -lavutil \ -lswscale \ -lswresample用 CMake 也差不多,核心点是头文件路径和库的链接顺序。FFmpeg 库之间有依赖层次,avdevice 依赖 avformat,avformat 依赖 avcodec,avcodec 依赖 avutil,顺序放反了链接器会报一堆未定义符号。如果你用 MSVC 编译 Qt 工程,务必保证 FFmpeg 库也是用同为 MSVC 的版本编译的,MinGW 编译出来的库和 MSVC 混用会在运行时出现内存布局问题,表现为莫名其妙的崩溃。
2.3 线程模型:取流循环必须和 UI 事件循环分离
从摄像头取流是阻塞式的,av_read_frame在等数据的时候会卡住当前线程,所以绝不能放在 UI 主线程里做。常见做法是开一个后台 QThread 或者在 std::thread 里跑采集循环,取到帧之后转换成 QImage,再通过信号槽投递到主线程去刷新。
这里最容易被忽视的点是 QImage 的所有权。跨线程传递 QImage,底层 QPixmap 或 QImage 的像素缓冲默认是隐式共享的,如果后台线程在发送信号之后马上又去复用这个缓冲,UI 线程刚收到的 image 可能已经被下一帧覆盖。我的做法是发送前强制detach()或者直接做一次深拷贝:
void FrameProducer::run() { while (!m_stop) { AVPacket packet; // av_read_frame, avcodec_send_packet, avcodec_receive_frame ... QImage image(m_width, m_height, QImage::Format_RGB32); // sws_scale 将 AVFrame 数据写入 image.bits() emit frameReady(image.copy()); // 深拷贝,防止 UI 线程读到被覆盖的数据 } }这段代码的逻辑是:取到一帧并完成格式转换后,把 QImage 拷贝一份再发信号。copy()会复制底层像素数据,这样才能保证 UI 线程拿到的是稳定的一帧。代价是每帧多一次内存拷贝,但换来了界面刷新没有任何竞态条件。你如果觉得拷贝开销大,可以用 QImage 的bits()指针做双缓冲交替采集,但工程复杂度会明显上升,新手不建议一上来就碰双缓冲。
3. 摄像头取流与画面显示:从 DShow 枚举设备到 AVFrame 转 QImage
3.1 打开 USB 摄像头:设备格式与关键参数配置
在 Windows 上 FFmpeg 走的是 dshow,在 Linux 上走 v4l2。无论是哪个平台,第一步都是avdevice_register_all(),让 FFmpeg 知道自己有哪些输入设备可用。然后通过av_find_input_format找到对应的 demuxer:
AVInputFormat *inputFormat = av_find_input_format("dshow"); // Windows // 或 av_find_input_format("v4l2"); // Linux AVFormatContext *fmtCtx = nullptr; AVDictionary *options = nullptr; av_dict_set(&options, "framerate", "30", 0); av_dict_set(&options, "video_size", "1920x1080", 0); av_dict_set(&options, "pixel_format", "yuyv422", 0); av_dict_set(&options, "rtbufsize", "1024000", 0); int ret = avformat_open_input(&fmtCtx, "video=USB Camera", inputFormat, &options); if (ret < 0) { // 打印错误,检查设备名和参数 }这段代码有四个细节值得说明。第一,video=USB Camera是 dshow 设备名,你需要先用 avdevice_list_devices 枚举一遍,否则写错名字打开直接失败。枚举出来的设备名一般是“USB Camera”或类似供应商字符串,不同摄像头不一样。第二,pixel_format别乱设,要看摄像头实际支持的格式。许多廉价 USB 摄像头原生只支持 YUY2 和 MJPEG,你直接指定 yuyv422 是安全的;如果摄像头硬件只输出 NV12,你写 yuyv422 会导致打开预览花屏。建议先用 FFmpeg 的枚举接口读设备支持的像素格式列表,再把选中的格式填进去。第三,rtbufsize是输入缓冲大小,1080p 下如果画面撕裂或丢帧明显,可以往上调。第四,avformat_open_input之后,摄像头不是立刻出流的,还需要用av_read_frame循环去取 packet,才真正开始驱动硬件。
3.2 从 AVPacket 到 AVFrame:新版解码 API 的正确用法
摄像头出来的数据是编码后的流。如果摄像头输出 MJPEG,那每个 AVPacket 就是一张完整的 JPEG 图;如果是 YUY2 原始流,那 packet 里就是裸帧。无论哪种情况,都需要经过解码器才能得到统一的 AVFrame 结构。FFmpeg 新版本的推荐 API 是 send/receive 模式:
while (true) { ret = av_read_frame(fmtCtx, &packet); if (ret < 0) break; ret = avcodec_send_packet(codecCtx, &packet); if (ret < 0) continue; while ((ret = avcodec_receive_frame(codecCtx, frame)) == 0) { // 拿到一帧解码后的 AVFrame processFrame(frame); } av_packet_unref(&packet); }注意一个关键点:av_read_frame返回 0 不代表你立刻能拿到一帧,你还要在里面循环avcodec_receive_frame,因为一次 send_packet 可能解码出零帧或一帧。MJPEG 的硬件编码器一般是一次一张图,所以 receive 循环通常只走一次就退出。
如果摄像头输出的是 MJPEG,你还需要在做解码之前告诉解码器解码格式:
AVCodec *decoder = avcodec_find_decoder(AV_CODEC_ID_MJPEG); AVCodecContext *codecCtx = avcodec_alloc_context3(decoder); codecCtx->pix_fmt = AV_PIX_FMT_YUVJ422P; // MJPEG 解码输出通常在 YUVJ 系列 codecCtx->width = 1920; codecCtx->height = 1080;pix_fmt设错是最常见的解码翻车原因。MJPEG 解码器输出的像素格式通常带 YUVJ 前缀,如果你默认用 AV_PIX_FMT_YUV420P 去接,sws_scale 转换时会报参数错误或者输出绿屏。稳妥的做法是解码器初始化后主动打印一次codecCtx->pix_fmt,用实际值去对应后续转换。
3.3 用 sws_scale 把 YUV 转成 QImage 并刷新画面
AVFrame 默认是 YUV 平面数据,Qt 的 QImage 不认识这种布局,必须转成 RGB32 或 ARGB32。用 sws_scale 来做这件事,性能比手写逐像素转换好很多,而且能正确处理色度和行对齐:
SwsContext *swsCtx = sws_getContext(width, height, srcPixFmt, width, height, AV_PIX_FMT_BGRA, SWS_BILINEAR, nullptr, nullptr, nullptr); QImage image(width, height, QImage::Format_RGB32); uint8_t *mDstData[1] = { image.bits() }; int mDstLinesize[1] = { image.bytesPerLine() }; sws_scale(swsCtx, frame->data, frame->linesize, 0, height, mDstData, mDstLinesize);这里有一个很多人都踩过的坑:image.bytesPerLine()和width * 4不一定相等。QImage 底层有行对齐机制,如果宽度刚好落在 4 字节对齐的边界上两者一致,否则 QImage 会额外补几个字节。而 AVFrame 的linesize[0]也有自己的对齐逻辑。所以传给 sws_scale 的目标 linesize 必须用image.bytesPerLine(),不能直接用width * 4去计算,否则每行末尾会错位,显示出来就是斜纹或者整幅画面偏移。
刷新到界面上,信号槽是最稳的方式,不要在子线程里直接操作 QWidget:
emit frameReady(image);然后在主线程的槽函数里setPixmap(QPixmap::fromImage(image))。如果你发现 1080p 显示 CPU 占比很高,试着把显示画面缩放成 720p 再贴到大窗口上,用image.scaled(label->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)会引入额外开销,更好的做法是 sws_scale 直接把目标尺寸缩到窗口大小,一次转换同时完成缩放和格式转换。
4. 同步录制 MP4:H.264 编码器初始化与时间戳校准
4.1 编码封装选型:libx264 与 MP4 容器的匹配
录制端的选型基本没有悬念:编码器用 libx264,容器用 MP4。H.264 是当前本机录像兼容性最好的格式,跨播放器、剪辑软件和硬件播放设备都认,MP4 容器对 H.264 + AAC 这类组合也支持得非常成熟。
FFmpeg 内部还提供了 h264_qsv、h264_nvenc、h264_amf 等硬件编码器,它们确实能把 CPU 占用压得很低,但硬件编码质量普遍不如 libx264 的同码率档位。录视频追求的是文件质量和可播放性,所以我推荐“预览用硬件解马或者直接收原始帧,录制统一走 libx264”这种混合理念。如果你后续要推流,再单独考虑硬件编码器也不迟。
编码器初始化的关键参数如下:
AVCodec *codec = avcodec_find_encoder_by_name("libx264"); AVCodecContext *encCtx = avcodec_alloc_context3(codec); encCtx->width = 1920; encCtx->height = 1080; encCtx->pix_fmt = AV_PIX_FMT_YUV420P; encCtx->time_base = (AVRational){1, 30}; encCtx->framerate = (AVRational){30, 1}; encCtx->bit_rate = 8000000; // 8Mbps,1080p 常见码率 encCtx->gop_size = 60; // 关键帧间隔 2 秒 encCtx->max_b_frames = 0; // 延迟优先,不插 B 帧 av_opt_set(encCtx->priv_data, "preset", "medium", 0); av_opt_set(encCtx->priv_data, "profile", "high", 0);max_b_frames设为 0 是很多实时录制场景的选择。B 帧能提升一点压缩效率,但会引入帧重排序,导致输出 DTS 和 PTS 不再严格相等,新手在封装阶段很容易被帧顺序搞晕。实时录制场景下,丢掉 B 帧换来的简洁逻辑是划算的。preset用 medium 是画质和编码速度的平衡点,如果你机器不强可以换 fast 或者 veryfast,代价是同等码率下细节稍差。
4.2 时间戳同步:为什么录出来的 MP4 会打不开或快进错乱
录制 MP4 时最容易踩的坑是时间戳不连续或起始不为零。MP4 的 mocv 原子严格要求每帧的 PTS 单调递增,一旦出现倒退或跳变,播放器轻则快进错乱,重则直接判定文件损坏。
解决方案是把“采集时间”和“录制时间”分开。采集线程拿到 AVFrame 后,不要直接用 frame->pts 去写文件,因为 USB 摄像头的时间基准可能和编码器的 time_base 不一致。正确做法是维护一个独立的录制帧计数:
static int64_t videoPts = 0; int64_t ptsInEncoderBase = av_rescale_q(videoPts, AVRational{1, 30}, encCtx->time_base); frame->pts = ptsInEncoderBase; videoPts++;av_rescale_q 是 FFmpeg 提供的高精度时间刻度转换函数,它计算(videoPts * 1/30) / (1/90000)这样跨时间基的换算,中间不会溢出不丢失精度。这样每帧 PTS 严格按编码器时间基递增,写出来的 MP4 时间轴一定是平稳的。
写入文件的 sequence 如下,这个顺序不能乱:
avformat_write_header(muxCtx, nullptr); while (进行中) { avcodec_send_frame(encCtx, frame); avcodec_receive_packet(encCtx, packet); av_interleaved_write_frame(muxCtx, packet); } av_write_trailer(muxCtx);av_interleaved_write_frame会自动处理 packet 的 interleave 排序,比av_write_frame更稳,强烈建议用它。写完所有帧之后必须调av_write_trailer,否则 MP4 没有 moov 索引,文件直接不可播。这就是很多人“录了一堆码流文件,打开全是错误”的根本原因。
4.3 录制模块封装:Start、WriteFrame、Stop 三段式接口
工程化的时候,我会把录制逻辑封装成一个独立类,对外只暴露三个方法:
class Mp4Recorder { public: bool Start(const QString& filePath, int width, int height, int fps); bool WriteFrame(AVFrame* frame); bool Stop(); };Start 内部完成编码器、格式上下文、AVIOContext 的初始化,再把avformat_new_stream挂上。WriteFrame 只干一件事:发送帧拿包,然后写入文件。内部要检查写包失败时是否继续重试,还是直接标记错误,我一般选择连续失败三次就终止录制,避免文件尾部破损。
Stop 里最容易被忽略的一步是“冲洗编码器”。因为编码器内部有缓冲,你最后几帧输入之后并不会立刻输出全部包,必须调avcodec_send_frame(ctx, nullptr)来 flush,然后继续拉包直到avcodec_receive_packet返回 AVERROR_EOF。把这个逻辑写进 Stop 里的第一段:
bool Mp4Recorder::Stop() { avcodec_send_frame(encCtx, nullptr); while (avcodec_receive_packet(encCtx, packet) == 0) { av_interleaved_write_frame(muxCtx, packet); av_packet_unref(packet); } av_write_trailer(muxCtx); // 依次释放编码器上下文、格式上下文 }如果不做这一步,录制文件时长会比实际采集少几帧,严重时最后几帧出现花屏或播放器报错。尤其在预览显示没问题、录出来却丢尾帧的场景,很可能就是这个 flush 漏了。
5. 避坑指南:摄像头采集与编码里最容易翻车的五个点
5.1 预览卡顿、帧率只有 10fps
现象:显示画面能出,但明显不流畅,CPU 一个核跑满,帧率上不去 30。
原因:USB 摄像头默认的像素格式可能是 YUY2 或 MJPEG,而你在解码转换时链路太长。如果摄像头硬件支持 MJPEG,FFmpeg 默认抓到的裸流可能是 30fps 的 MJPEG 编码数据,这本来效率很高;但如果摄像头只输出 YUY2,1080p@30fps 的 USB 带宽开销极大。此时要么降低分辨率,要么把采集格式改成 MJPEG 再解码。
解决:在 avformat_open_input 之前设置pixel_format为 mjpeg,然后解码器解码 JPEG,这样 USB 带宽只有原生 YUY2 的几分之一,帧率很容易跑满。注意改完格式后解码器的AV_CODEC_ID也要同步改成 MJPEG。
5.2 录出来的 MP4 无法打开
现象:录制结束,文件大小也正常,但用播放器打开提示文件损坏或看不到画面。
原因:八成是没写 trailer,或者写了 trailer 但中途写帧失败导致 moov 索引不完整。
解决:确认录制结束路径必然走到av_write_trailer,同时每次写完 packet 之后检查返回值。如果中途掉帧严重,宁可主动截断文件并提示录制中断,也不要生成一个无法修复的文件。另一个隐蔽原因是外面只调了av_write_frame而不是av_interleaved_write_frame,MP4 需要 interleave 后的帧序,直接写裸包会导致容器结构错乱。
5.3 画面颜色发绿或偏蓝
现象:预览画面颜色整体不对,人脸变成绿脸,或者画面出现大量色斑。
原因:颜色空间标志没配对。AVFrame 从解码器出来时一般都带着color_primaries、color_trc和colorspace信息,而你做 sws_scale 之前没有把SwsContext的输入颜色空间设置成和帧一致。另一个常见原因是源像素格式是YUVJ变体,和普通YUV420P的色度 range 不同,混合使用会偏色。
解决:在创建 SwsContext 之前,把 frame 里的 colorspace 字段拷贝到转换目标上下文里。也可以强制指定srcRange和dstRange:
sws_setColorspaceDetails(swsCtx, sws_getCoefficients(frame->colorspace), frame->color_range, sws_getCoefficients(SWS_CS_BT709), 1, 0, 0, 1);这里 BT709 是 1080p 视频的标准色彩空间,部分摄像头会用 BT601,和前者的差别在低分辨率视频上不明显,1080p 下就非常扎眼。验证方式是用 FFmpeg 命令行跑一帧转成 PNG,颜色正常了再回来看程序。
5.4 程序退出时崩溃
现象:点击关闭按钮后,程序经常卡住或直接 segfault,日志显示发生在析构阶段。
原因:线程和对象销毁顺序不正确。后台采集线程还在av_read_frame中阻塞,主线程已经销毁了 AVFormatContext 和 QImage 相关的资源。FFmpeg 的上下文一旦释放,正在阻塞的读帧接口会返回异常或直接访问已释放内存。
解决:退出顺序必须是“先停下来线程,再释放资源”。在关闭函数里先置 m_stop 标志,再调用av_read_frame打断阻塞,等线程 join 返回之后,才释放解码器、格式上下文、SwsContext 和编码器上下文。用一个状态机来保证这个顺序,比在析构函数里猜当前状态靠谱得多。
5.5 摄像头热插拔或掉线后无法重连
现象:USB 摄像头拔掉再插上,或者睡眠唤醒之后,程序再打开设备就报错或者一直黑屏。
原因:设备句柄已经被操作系统释放,FFmpeg 那边的上下文还保持着旧设备的状态。dshow 对设备热插拔的支持并不优雅,它不会主动通知应用程序。
解决:在采集循环里检测av_read_frame的返回值。如果返回AVERROR(EIO)或AVERROR_EOF,立刻断开整个上下文,然后进入重连循环,每隔 1 秒尝试打开一次设备。重连时所有 Context 都要重新初始化,不能复用旧对象。
6. 进阶优化:低延迟调参组合与丢帧策略
基础版本能跑通之后,下一步要处理的是延迟和丢帧。预览延迟指的是摄像头物理世界里的动作,到 Qt 界面显示出来的时间差。普通的“取帧-解码-转换-显示”流程,延迟在 150ms 到 300ms 之间,如果画面里有人在移动,这个延迟已经能明显感觉到了。要压低延迟,核心思路是“减少缓冲”和“跳过积压帧”。
采集端可以这样调:avformat_open_input 时加两个选项。
av_dict_set(&options, "fflags", "nobuffer", 0); av_dict_set(&options, "probesize", "32", 0); av_dict_set(&options, "analyzeduration", "0", 0);fflags=nobuffer让 FFmpeg 不要往内部缓冲里堆超过一帧的数据,probesize调小能加快启动首帧速度,analyzeduration=0关闭长时间探测导致的开流延迟。这几个参数改完之后,首帧出现时间能压缩到 200ms 以内。
解码和显示端再跟上:
switch (preset) { case 延迟敏感: av_opt_set(encCtx->priv_data, "tune", "zerolatency", 0); encCtx->flags |= AV_CODEC_FLAG_LOW_DELAY; break; case 画质优先: av_opt_set(encCtx->priv_data, "preset", "slow", 0); break; }zerolatency 会禁止编码器做任何前瞻性缓冲,把编码延迟压到极限,代价是压缩率轻微下降。预览和录制走同一路编码时,这个组合最合适。
最后是丢帧策略。USB 摄像头本身不稳定,场景快速变化时编码器可能来不及处理每一帧,盲目把所有帧都塞进编码队列会导致延迟逐渐增大。我的做法是每取到一帧先判断之前是否还欠着帧没处理完,如果积压超过 3 帧,直接丢弃当前帧:
int framesBacklog = avcodec_get_encode_queue_size(encCtx) + (receiveLoopPending ? 1 : 0); if (framesBacklog > 3) { av_frame_free(&frame); continue; }实时传输场景下,丢帧比延迟堆积危害小得多。用户宁可看到画面轻微跳帧,也不愿意看到动作和声音越拉越远。这套丢帧逻辑我通常会做成可配置项,录制时允许稍微积压,预览时强制丢帧。
做完这一轮调优,我每次接新摄像头都强制走一遍同样的流程:先枚举设备支持的像素格式,再选一个能跑满 30fps 的组合;启动后用日志确认首帧延迟和平均编码耗时;最后模拟一次掉线和重连,验证自动恢复逻辑。这套流程帮我避免了很多“换了一台摄像头就翻车”的尴尬,希望也帮到你。
本文还有配套的精品资源,点击获取