简介:一套采用FFMPEG与Qt框架联合实现的视频采集工程源码,面向需要为USB摄像头添加1080p实时预览和MP4同步录制功能的开发者,也可作为系统学习音视频处理的基础范例。工程文件完整,共182个文件,压缩包487KB;其中144个h头文件整理了FFMPEG调用接口与数据结构,3个cpp源文件承担采集线程和主窗口逻辑,pro与ui文件分别提供工程配置和界面布局,另配lib/a库及def定义文件,便于链接和替换版本,整体简洁适合二次开发。已有844人学习下载,代码经过实际验证,可有效缩短开发周期并降低调试成本。通过阅读源码可梳理摄像头打开、帧读取、Qt渲染显示、H.264编码及MP4封装输出的完整链路;针对不同分辨率或码率,只需调整相关参数即可迁移到网络监控、视频会议、远程教学等情景,具备较好的实用与参考价值。
1. 项目定位:这台“能预览又能录制”的Qt+FFmpeg摄像头工具解决了什么问题
做桌面工具的人多少都遇到过这种需求:客户要一个能实时预览USB摄像头画面、还能同步存成mp4的小软件。第一反应是用Qt自带的QCamera,试过就知道它只能拿到封装好的QImage,帧率、编码方式、封装格式全被框架锁死了,改一个录制码率都要绕半天。后来直接用FFmpeg拉设备,才发现控制权完全握在手里,1080p预览加录制只是最基本的组合。
这个项目做的就是这件事:用FFmpeg从底层读取本地USB摄像头帧数据,交给Qt渲染到窗口上,同时把同一路帧送进编码器写为mp4文件。适合两类人看:一是刚接触FFmpeg但想用Qt做界面的开发者,可以照着这个结构理解“采集—显示—编码”三条链路怎么共存;二是手头有现成Qt工程、需要补一个摄像头模块的人,核心代码可以直接摘出去改。整个工程不依赖第三方播放器,不依赖QCamera,采集到显示走的是FFmpeg的AVFrame,录制的mp4可直接用播放器打开。
2. 技术选型与整体架构:为什么是FFmpeg而不是QCamera或VLC
2.1 选型对比:QCamera、VLC与FFmpeg的边界在哪里
先说结论:这个场景下FFmpeg是代价最小的一条路。QCamera的优点是上手快,但它把采集参数封装成了黑匣子,无法直接控制像素格式、编码器预设、时间基和容器的交错写方式。而FFmpeg把USBCamera看成一种输入格式(Windows下是dshow,Linux下是v4l2),能拿到最原始的AVPacket和AVFrame,像素转换、缩放、编码、封装全都可以自己决定。这意味着你可以在录制前给视频流插一个自定义滤镜,或者把同一帧同时送给预览和编码两条通路,始终只有一个数据源,不会有两条流错位的问题。
有人会问,为什么不用VLC的libvlc。libvlc的RTSP/网络流播放很强,但对本地USB设备的控制粒度不够:拿不到单帧AVFrame做算法处理,也没法直接把帧数据交给Qt渲染而不经过中间缓冲。对于纯本地预览+录制,libvlc等于绕了一圈。相比之下FFmpeg更接近“设备驱动层+编解码库”的组合。
2.2 整体架构:采集线程、显示线程、编码线程怎么分工
整个工程可以从线程模型来理解。第一条是采集线程,它负责从设备反复读取AVPacket,解析出视频流并解码成AVFrame,然后做一次像素格式转换(通常是YUV420P转RGB32),转完之后把帧“推”给显示通路和编码通路。第二条是显示通路,运行在Qt的主线程或一个独立的渲染线程里,拿到RGB32数据后构造QImage,update到QLabel或QWidget上。第三条是编码通路,另一条执行线程里维护一个编码器和mp4封装器,接收同样的AVFrame(保持YUV420P),交给H.264编码器后写入文件。
三条链路之间一般用生产者消费者模式衔接。常见做法是采集线程每拿到一帧,就更新一个受互斥锁保护的“最新帧”指针,显示通路只关心最新一帧,宁可丢旧的也要保证画面即时;而编码通路则用一个有界队列缓冲,保证每帧都按顺序进编码器,不必塞满,防止内存涨爆。这样设计的好处是:显示卡顿不会阻塞采集,编码跟不上时显示仍然流畅,互不拖累。
2.3 工程目录划分:一个最小可跑工程的模块切片
以Windows + MSVC + Qt 5.15为例,工程的最小目录结构大致长这样:
camera_app/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.h / MainWindow.cpp │ ├── CameraThread.h / CameraThread.cpp │ ├── EncodeThread.h / EncodeThread.cpp │ └── FrameBuffer.h / FrameBuffer.cpp └── third_party/ └── ffmpeg/ ├── include/ └── bin/CameraThread负责avformat_open_input到av_read_frame再到sws_scale这个流程,产出AVFrame交给FrameBuffer。EncodeThread从FrameBuffer取帧,通过avcodec_send_frame和avcodec_receive_packet拿到编码后的包,再用av_interleaved_write_frame写进mp4。FrameBuffer是一个带锁的环形缓冲或双缓冲,分隔采集端和消费端的节奏差异。MainWindow只做两件事:把CameraThread转出来的QImage渲染出来,以及提供开始录制/停止录制的按钮,按钮动作通过信号槽告诉EncodeThread何时创建mp4、何时关闭封装器。
3. 环境搭建与工程配置:让FFmpeg和Qt在Windows与Linux上顺利握手
3.1 库的获取:Windows下MSVC和MinGW的ABI差异
FFmpeg在Windows下最常踩的坑是运行时库不一致。MSVC编译的工程必须链接MSVC版FFmpeg,MinGW工程必须链接MinGW版,混用会在avformat_open_input阶段出现内存崩溃或者奇怪的函数指针错误。我现在用的都是BtbN发布的Windows构建,下载后把include目录加入CMake头文件搜索路径,把bin目录里的dll拷到exe输出目录就可以跑。注意FFmpeg的dll有两类,一类是avcodec-*.dll这种带版本号的运行库,另一类是项目自己的接口dll,实际写CMake时直接链avformat、avcodec、avutil、swscale、swresample这五个库就行。
Linux下不用纠结ABI,Ubuntu等发行版直接通过apt安装开发库即可。Debian系安装命令一般是apt install libavformat-dev libavcodec-dev libavutil-dev libswscale-dev,CentOS则需要用dnf install ffmpeg-devel。macOS如果通过Homebrew安装FFmpeg时没有开启shared选项,编译时可能会遇到找不到动态库的问题,基本都跟ffmpeg和ffmpeg-devel包是否同时安装相关。
3.2 CMakeLists.txt配置:链接顺序与编码器选择
FFmpeg的库之间是分层的,链接顺序错了会报unresolved external symbol。常见的顺序是avformat排在前面,接下来avcodec、avutil、swscale、swresample,有些项目还需要swresample做音频重采样,本项目只做视频,可以省略。Qt的组件用Widgets和Multimedia即可,注意Multimedia多用于设备枚举,数据通路不依赖它。
cmake_minimum_required(VERSION 3.16) project(CameraApp) set(CMAKE_CXX_STANDARD 17) find_package(Qt5 REQUIRED COMPONENTS Widgets Multimedia) find_path(FFMPEG_INCLUDE_DIR libavformat/avformat.h) find_library(AVFORMAT_LIB avformat) find_library(AVCODEC_LIB avcodec) find_library(AVUTIL_LIB avutil) find_library(SWSCALE_LIB swscale) find_library(SWRESAMPLE_LIB swresample) add_executable(CameraApp src/main.cpp src/MainWindow.cpp src/CameraThread.cpp src/EncodeThread.cpp ) target_include_directories(CameraApp PRIVATE ${FFMPEG_INCLUDE_DIR}) target_link_libraries(CameraApp PRIVATE Qt5::Widgets Qt5::Multimedia ${AVFORMAT_LIB} ${AVCODEC_LIB} ${AVUTIL_LIB} ${SWSCALE_LIB} ${SWRESAMPLE_LIB} )这里有一个容易忽略的点:链接顺序决定静态库符号解析是否成功,如果后续代码里涉及到图片保存,可能还需要链入avdevice这个库,它在有些构建里单独存在,功能是设备输入输出。涉及USB设备枚举时不显式链接avdevice,运行时往往在avformat内部已经初始化过了,但显式写上更保险,避免个别平台加载不到。
3.3 最小窗口骨架:让采集线程跑起来
工程不需要太复杂的界面,一个QMainWindow上放一个QLabel用于显示画面,再加两个QPushButton控制录制开始和停止。CameraThread继承QThread,重写run(),在这个函数里完成设备的打开、循环读帧、格式转换和信号发送。Qt的信号槽在不同线程之间传递QImage时,会自动通过事件队列完成拷贝,但对于1080p帧来说,这其实是有开销的。等讲到第四章时会细说怎么避免这种QImage拷贝导致的卡顿。
4. 核心代码拆解:一个视频帧从摄像头到mp4落盘的完整链路
4.1 打开设备:avformat_open_input的参数细节
采集线程的第一步是把USB设备当作一个输入文件打开。在Windows上使用dshow作为输入格式,在Linux上使用v4l2。设备的URL在Windows下形如video=USB Camera,这个名称要和系统设备列表里的名字一致,常见的枚举方法是运行ffmpeg -list_devices true -f dshow -i dummy查看,也可以在代码里用avdevice_list_devices主动枚举。Linux下则是/dev/video0这样的设备节点。
AVFormatContext* fmt_ctx = 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); AVInputFormat* ifmt = av_find_input_format("dshow"); int ret = avformat_open_input(&fmt_ctx, "video=USB Camera", ifmt, &options); if (ret != 0) { // 读取失败,多半是设备被占用或名称不匹配 } ret = avformat_find_stream_info(fmt_ctx, nullptr);这段代码里有两个关键参数要说明。framerate=30是通知驱动输出30fps,如果设备不支持会自动协商到最接近的帧率;video_size指定采集分辨率,这里直接给1920x1080省去后续缩放,但要注意的是有些设备只支持640x480或1280x720等固定档位,传一个不支持的尺寸时dshow会自动选一个最接近的。pixel_format最好先不指定,让驱动输出默认格式,然后通过av_find_best_stream和codecpar去读取实际格式,再决定sws_scale怎么转。
打开之后别忘了调用avformat_find_stream_info填充流的元信息,后续拿时间基、帧率信息都依赖这一步,漏掉的话后面时间戳计算没有任何参考。
4.2 读帧与像素转换:av_read_frame和sws_scale的组合
打开设备后进入循环读帧。av_read_frame拿到的AVPacket在视频流里通常已经包含了压缩后的数据,但USB摄像头这种裸设备上的dshow输入,很多时候packet里是原始视频帧或者MJPEG压缩帧。为了统一后续处理,最稳妥的方式是找到视频流对应的解码器,用avcodec_send_packet和avcodec_receive_frame解出AVFrame,再交给sws_scale。
AVPacket* pkt = av_packet_alloc(); AVFrame* frame = av_frame_alloc(); AVFrame* rgb_frame = av_frame_alloc(); while (is_running) { int ret = av_read_frame(fmt_ctx, pkt); if (ret < 0) { break; } if (pkt->stream_index == video_stream_index) { ret = avcodec_send_packet(codec_ctx, pkt); if (ret == 0) { while (avcodec_receive_frame(codec_ctx, frame) == 0) { // 原始帧在手,准备像素格式转换 sws_scale(sws_ctx, frame->data, frame->linesize, 0, codec_ctx->height, rgb_frame->data, rgb_frame->linesize); // 把 rgb_frame 包装成 QImage,发信号到 UI QImage image(rgb_frame->data[0], codec_ctx->width, codec_ctx->height, QImage::Format_RGB32); emit frameReady(image); // 同时把原始 frame 拷贝给编码线程 encodeThread->pushFrame(frame); } } } av_packet_unref(pkt); }这里值得展开的细节是sws_scale到底做了什么事。USB摄像头输出的原始格式通常是YUV420P、NV12或YUYV422,Qt的QImage只认RGB,所以每次拿到一帧就要做一次色彩空间转换。sws_getContext在初始化时指定源格式和目标格式,这里把源格式设置成codec_ctx的pixel_fmt,目标格式设置为AV_PIX_FMT_RGB32。注意RGB32在内存里实际是BGRA顺序,QImage需要配成Format_RGB32或Format_ARGB32,否则颜色会错乱成红蓝互换。
rgb_frame需要提前用av_frame_get_buffer分配好内存,并保证linesize对齐。常见做法是根据目标宽高计算:
rgb_frame->format = AV_PIX_FMT_RGB32; rgb_frame->width = codec_ctx->width; rgb_frame->height = codec_ctx->height; av_frame_get_buffer(rgb_frame, 32);4.3 显示端:避免QImage拷贝的两种常见做法
上面代码里直接构造了一个QImage并把rgb_frame的数据指针传进去。问题在于QImage默认不拥有数据,它只保存指针和参数,等到界面线程真正绘制时,采集线程可能已经把这块内存写到下一帧了。解决这个问题有两种思路。
第一种是修改采集线程,在emit之前用image.copy()做一次深拷贝。正常人第一版都是这么写的,1080p下每次拷贝大约8MB内存,虽然看起来有点重,但实测通常不会成为瓶颈。第二种是维护一个受锁保护的“当前帧”QImage,采集线程每次写完rgb_frame后直接覆盖这个QImage,UI线程用定时器高频update,这种做法消除了跨线程拷贝,代价是需要程序员自己控制锁粒度,避免UI读到写了一半的帧。
我一般会选择第二种,但把锁换成双缓冲。即采集线程交替写frameA和frameB,UI线程读取非正在写的那个buffer,通过一个atom标志位切换。这样的好处是不会因为锁竞争阻塞UI,也不存在撕裂问题。实际工程里QLabel的setPixmap本身有拷贝开销,如果你的目标是1080p@30fps,建议直接用QOpenGLWidget加纹理渲染,省掉QImage那一步。
4.4 编码与封装:H.264编码器和mp4 muxer怎么配合
编码线程的职责是从队列里取AVFrame,送进H.264编码器,拿到AVPacket之后再交给mp4封装器写入文件。打开编码器的参数决定文件体积、画质和回放兼容性。
// 初始化输出封装器 avformat_alloc_output_context2(&out_ctx, nullptr, "mp4", filePath); AVStream* out_stream = avformat_new_stream(out_ctx, nullptr); AVCodec* codec = avcodec_find_encoder(AV_CODEC_ID_H264); AVCodecContext* enc_ctx = avcodec_alloc_context3(codec); enc_ctx->width = 1920; enc_ctx->height = 1080; enc_ctx->time_base = (AVRational){1, 30}; enc_ctx->framerate = (AVRational){30, 1}; enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P; enc_ctx->bit_rate = 4000000; enc_ctx->gop_size = 60; enc_ctx->max_b_frames = 0; avcodec_open2(enc_ctx, codec, nullptr); avcodec_parameters_from_context(out_stream->codecpar, enc_ctx); avio_open(&out_ctx->pb, filePath, AVIO_FLAG_WRITE); avformat_write_header(out_ctx, nullptr);这里几个参数值得记一下。bit_rate=4000000约等于4Mbps,在光线较好的环境下1080p30的画面基本够用,如果拍摄快速运动的屏幕内容,建议提到8Mbps以上。gop_size=60表示每60帧一个关键帧,配合30fps正好2秒一个I帧,拖动进度条时不会出现长时间模糊画面。max_b_frames=0意味着不使用B帧,B帧会引入相对复杂的解码逻辑,并带来一定延迟,对实时录制场景没有太大意义,关掉能让文件兼容性更好。
编码循环的核心是avcodec_send_frame和avcodec_receive_packet的配对调用:
int ret = avcodec_send_frame(enc_ctx, frame); if (ret < 0) return; while ((ret = avcodec_receive_packet(enc_ctx, pkt)) == 0) { av_packet_rescale_ts(pkt, enc_ctx->time_base, out_stream->time_base); pkt->stream_index = out_stream->index; av_interleaved_write_frame(out_ctx, pkt); av_packet_unref(pkt); }av_interleaved_write_frame是封装器写入mp4的函数,它会在内部做交错处理。编码器输出时间戳的单位是编码器的time_base,而mp4容器期望的单位是out_stream自己的time_base,所以写入前必须用av_packet_rescale_ts换算一次,否则录出来的文件时间轴是乱的,播放器会跳秒或卡在某一帧。
录制完成时,需要先调用avcodec_send_frame传入nullptr表示flush,把编码器缓冲里尚未输出的一两帧取出来,全部写到文件里,然后调用av_write_trailer结束mp4封装。漏掉flush或trailer是录制文件打不开的最常见原因。
5. 踩坑排查:1080p采集卡顿、绿屏、文件损坏等五个现场
5.1 画面卡在最后一帧,CPU占用持续飙高
现象是打开摄像头后预览正常,但移动物体时画面延迟越来越严重,最终完全“痴呆”,CPU占用却居高不下。原因不是显示端拖沓,而是采集线程在一个无锁循环里疯狂调用av_read_frame,没有做帧率限制,导致QT事件循环被信号淹没,每帧都在排队。
解决方法是按设备真实帧率让出调度:每次循环结束统计一下已用时间,如果比33毫秒短就sleep剩余的差值。更精确的做法是利用av_read_frame返回的packet.pts,后续再考虑复杂的时间戳调度。从那次之后,我的采集线程一律加上帧率门控,而不是依赖驱动自发均匀送帧。
5.2 录制的mp4播放器打不开
现象是最难受的,预览一切正常,停止录制后文件存在,但双击播放器直接报错。排查发现多数情况是停止流程只退出了while循环,没有调用av_write_trailer。mp4封装器必须在最后写入moov索引,这个原子段记录着每一帧的偏移位置,没写trailer就相当于一本书没有目录,大多数播放器没法解析这种文件。
解决方法是把停止逻辑做成一个有状态机控制的流程:先入队列一个“停止哨兵”,编码线程处理到它之后,先send null flush编码器,再收走残留packet,最后av_write_trailer并avio_close。从那以后我每次写录制功能都会强制走一遍这个收尾逻辑,再没遇到过打不开的文件。
5.3 预览画面绿屏但不崩溃
现象是视频窗口满屏绿色噪点,或者颜色完全反相。原因往往不在FFmpeg,而在QImage格式和sws输出的内存排布不匹配。AV_PIX_FMT_RGB32在内存里实际上是BGRA顺序,这与QImage::Format_RGB32要求的内存排布一致,但如果你设置了AV_PIX_FMT_BGR24就要用Format_RGB888,设置成AV_PIX_FMT_RGB24则要用Format_BGR888。很多项目的翻车现场都是直接把RGB24对应到Format_RGB888,红蓝通道对调导致整屏发绿发紫。
排查起来也简单,先单独把一帧存成bmp文件看颜色是否正确,排除Qt渲染问题后再看格式映射,这样能快速二分定位。
5.4 录像文件时长和实际时间对不上,倍速播放
现象是录制了30秒视频,媒体播放器显示时长只有20秒或40秒,播放速度明显不正常。原因基本是容器时间戳基准没有校准。mp4封装期望发送格式为AV_TIME_BASE或stream time_base的pts/dts,而裸的AVFrame自带pts可能是以1/1000秒为单位的,未做av_packet_rescale_ts就直接写入,导致播放器按错误速率换算画面时间。
解决方案是用av_packet_rescale_ts统一到输出流的时间基,这一步不是可选项。注意帧率不为整数时(例如29.97),直接在容器里写固定time_base会导致严重的音画不同步,此时更稳妥的是在采集阶段就打开精确时间基。
5.5 设备名称含特殊字符导致打不开
现象是Windows下某些USB摄像头名称里带括号或“&”字符,直接拼接URL传给avformat_open_input会失败。dshow的设备URL格式不是普通字符串拼接,特殊字符需要转义。常见做法是用avdevice_list_devices枚举并获取device的url字段,不要自己拼。代码侧可以先注册avdevice,再通过avdevice_list_input_sources拿到AVDeviceInfoList,从列表里直接取device_name,这样在设备被占用时还能反馈用户一条“设备已被其他程序锁定”的提示。
6. 二次开发思路:截图、分辨率切换和RTSP推流怎么动刀
读到这里的读者通常已经不满足于“能跑就行”。这个工程最值得扩展的地方有三个,按性价比排序。
第一个扩展是截图功能。已经有了rgb_frame,只要在当前帧转出来的同时把它交给一个图片编码器,例如直接用avcodec编码成MJPEG或PNG就能落盘。常见做法是把编码图写入到${时间戳}.png,耗时不超过几十毫秒,不干预主链路。
第二个扩展是分辨率切换。当前代码把width和height写死在初始化里,做成动态其实不难:先停掉采集线程和编码线程,释放上下文,再按新参数重建,保底思路是识别设备支持的若干个分辨率档位后在UI层用下拉框切换。注意直接改编码器的width而不重建输出上下文,many video文件会因参数不匹配而写不进去。
第三个扩展是推RTSP而不是录mp4,这个改动比想象中要小。把avformat_alloc_output_context2的输出格式从"mp4"换成"rtsp",URL从本地文件路径换成rtsp://ip:port/stream,编码器从H.264换成libx264或硬件编码器,其余send_frame/receive_packet和写包逻辑完全不用变。推流场景需要额外处理带宽限制,把bit_rate降低到2Mbps左右,并根据网络状态增加丢帧策略,否则画面会卡成幻灯片。
最后说一句我的习惯:这个工程每次改完参数,都会强制走一遍检查清单——打开设备、确认驱动输出格式、拉一帧校验颜色、录十秒验一遍时间戳、最后用播放器快进拖动测试。步数多,但比起改完就交付然后被客户返工,前者要省心得多。希望这篇笔记能帮到正在跟FFmpeg和摄像头较劲的你。
本文还有配套的精品资源,点击获取