简介:这份代码包围绕 C# 与 OpenCVSharp 实现 USB 摄像头视频采集与 H264 编码录像,面向需要快速搭建视频录制功能的 .NET 开发者,也可用于视频监控、网络直播、视频通讯等场景的入门学习与二次开发。包内共 134 个文件,以 47 个 dll 依赖、10 个 cs 源码、5 个 exe 以及缓存文件、项目配置为主,压缩包约 56.36MB,包含解决方案与工程配置,便于还原编译环境。已有 290 人学习,说明其在同类实践中有一定参考价值。读者可从中了解摄像头枚举、分辨率与帧率设置,VideoWriter 创建及 H264 编码参数配置,逐帧读取写入、图像格式转换和录制结束后的资源释放等关键环节;也可借助现成工程对照调试,排查编码失败、视频文件不完整等问题,减少从零搭建环境与反复试错的时间,适合对照源码理解 OpenCVSharp 视频写入流程。
1. 一台 200 万像素的 USB 摄像头,怎么在 C# 里稳定录成 H264
上周有同事抱着一台 200 万像素的 USB 摄像头过来,说他用 C# 录了十分钟,文件能播,但画面比实际快了一倍,问我是不是 OpenCvSharp 的锅。这套 USB 摄像头录像 的活儿我做过几轮,结论是:C# 侧用 OpenCvSharp 把帧抓出来只算热身,真正决定成败的是怎么把一堆 BGR 帧稳定喂给 H264 编码器并落成 mp4。抓帧、编码、写盘三件事的节奏不一样,中间还夹着掉帧、编码器 DLL 缺失、磁盘抖动这些玄学问题。这篇拆的是一个典型工程:WinForm/WPF 桌面端、OpenCvSharp4 采集、H264 编码录制。适合已经能把摄像头画面显示出来、但一按录制就翻车的开发者,也适合想把录制链路封装成模块的人。
2. 工程骨架:OpenCvSharp4 托管包与原生 DLL 怎么摆
先把依赖关系理清,不然后面所有报错都会指向一个黑匣子。OpenCvSharp 是托管封装,真正干活的是原生 OpenCV 的 dll;H264 编码又额外依赖一个编码器动态库。三者版本错位,症状往往不是「找不到文件」,而是「打开成功、写入为空」这种更难查的表现。
2.1 托管包选型:主体包与运行时包的分工
我一般只装两个包:OpenCvSharp4提供VideoCapture、VideoWriter、Mat这些类型,OpenCvSharp4.runtime.win提供OpenCvSharpExtern.dll以及opencv_videoio_ffmpeg4xx_64.dll这类原生库。前者跟着项目走,后者负责把原生二进制铺到输出目录。
要注意的是,运行时包里的opencv_videoio_ffmpeg只带了解码侧能力,封装里通常不含 H264 编码器实现。这就是为什么很多人VideoWriter.IsOpened()返回 true,录完却是 0 字节——写入器对象构建成功了,第一帧编码时才失败。所以工程里还得单独放一份 OpenH264 的动态库(形如openh264-1.8.0-win64.dll),命名和位数必须与进程一致,32 位进程放 64 位 dll 一样是静默失败。
如果项目要上 x86 和 x64 双平台,建议按平台分目录,用 MSBuild 条件拷贝,别指望一个libs目录通吃:
<!-- 按平台把原生库拷到输出目录,避免 x86/x64 混放 --> <ItemGroup Condition="'$(Platform)' == 'x64'"> <None Include="libs\x64\*.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <Link>%(Filename)%(Extension)</Link> </None> </ItemGroup> <ItemGroup Condition="'$(Platform)' == 'x86'"> <None Include="libs\x86\*.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>这段配置的作用是把原生 dll 直接铺到bin根目录,而不是留在子文件夹里。Windows 的 DLL 搜索顺序先看进程目录,OpenCvSharpExtern.dll被加载后会按自己的规则找同级依赖,放在runtimes\win-x64\native下虽然 NuGet 能自动处理一部分,但 OpenH264 这种手动放进去的库经常找不到。Condition用来隔离平台,PreserveNewest保证换版本时会覆盖。踩过的坑是:只在 Debug 配好了拷贝,Release 打包给测试同事,对方机器上没装对应运行时,直接DllNotFoundException。
2.2 目录结构:把「谁依赖谁」写成看得见的层级
我拆过的几个能长期维护的录制工程,目录基本长这样,核心是把采集、编码、封装三层切开,原生库单独一栏:
ProjectRoot/ ├─ src/ │ ├─ Capture/ # VideoCapture 封装、设备枚举 │ ├─ Encoder/ # VideoWriter 与 FFmpeg 管道两种实现 │ ├─ Recording/ # 录制会话、分段、状态机 │ └─ UI/ # 预览窗口、按钮、状态栏 ├─ libs/ │ ├─ x64/ # OpenCvSharpExtern、openh264、ffmpeg 相关 dll │ └─ x86/ └─ tools/ └─ ffmpeg.exe # 仅在管道方案里用,随包分发Capture层只负责吐帧,对外暴露一个事件或回调;Encoder层实现一个接口,比如IVideoEncoder,有两个实现:OpenCvVideoWriterEncoder和FFmpegPipeEncoder。这样做的好处是编码路径可以运行时切换——OpenH264 初始化失败就降级到管道方案,用户侧几乎无感。Recording层管会话生命周期:开始、暂停、分段、停止、异常收尾。
提示:
tools/ffmpeg.exe建议按需分发。如果只用 OpenH264 一条路,就别把几十兆的 exe 塞进安装包,否则每次发版都要解释体积为什么变大。
2.3 一个最小可运行的骨架
先保证程序能启动、能列出设备、能打开第一个摄像头,再往里加录制。下面这段是启动时自检的写法,把「缺库」这个最常见的失败点前置到启动阶段,而不是等用户按下录制才炸:
using OpenCvSharp; // 启动自检:验证原生库与采集后端是否可用 static bool ProbeEnvironment(out string message) { try { // 触发一次原生调用,缺 dll 会在这里抛异常 var devices = VideoCapture.GetDevices(); if (devices.Length == 0) { message = "未检测到视频输入设备"; return false; } using var cap = new VideoCapture(0, VideoCaptureAPIs.DSHOW); if (!cap.IsOpened()) { message = "设备被占用或驱动异常"; return false; } message = $"设备数 {devices.Length},默认设备可打开"; return true; } catch (DllNotFoundException ex) { // 原生库缺失或位数不匹配 message = $"原生库加载失败:{ex.Message}"; return false; } catch (TypeInitializationException ex) { message = $"OpenCV 初始化失败:{ex.InnerException?.Message}"; return false; } }VideoCapture.GetDevices()是较新版本才有的静态方法,老版本要退回逐个索引试着打开。VideoCaptureAPIs.DSHOW明确指定 DirectShow 后端,比让 OpenCV 自动挑更可控——自动挑有可能选到 MSMF,而 MSMF 在部分采集卡上协商分辨率的行为很怪。两个catch分开写是有意为之:DllNotFoundException说明是部署问题,TypeInitializationException多半是原生库版本对不上,报错文案指向的排查方向完全不同。
3. 采集链路:VideoCapture 协商 MJPG 与 30fps 的真实姿势
采集这一层的目标不是「能读到帧」,而是「按稳定的节奏读到固定尺寸、固定像素格式的帧」。USB 摄像头带宽有限,1080p30 的未压缩 YUY2 数据量已经接近 USB2.0 的上限,所以能不能协商到 MJPG 压缩传输,直接决定你拿到的是 30fps 还是 5fps。
3.1 打开设备:后端选择与参数协商顺序
参数设置顺序是有讲究的,先 FourCC 再分辨率,最后 FPS。很多摄像头在设置分辨率时才决定用哪种像素格式,如果你先设了 1920x1080 再设 MJPG,驱动可能已经按 YUY2 分配好带宽,后面的 FourCC 设置会被忽略,然后你就得到一个降帧的画面。
using OpenCvSharp; // 打开 0 号 USB 摄像头,按「格式 -> 分辨率 -> 帧率」的顺序协商 using var cap = new VideoCapture(0, VideoCaptureAPIs.DSHOW); if (!cap.IsOpened()) throw new InvalidOperationException("摄像头打开失败,检查是否被其他程序占用"); // 1. 先协商 MJPG,压缩传输才可能跑满 1080p30 cap.Set(VideoCaptureProperties.FourCC, VideoWriter.FourCC('M', 'J', 'P', 'G')); // 2. 再设分辨率 cap.Set(VideoCaptureProperties.FrameWidth, 1920); cap.Set(VideoCaptureProperties.FrameHeight, 1080); // 3. 最后设帧率 cap.Set(VideoCaptureProperties.Fps, 30); // 回读实际生效值,驱动可能只给了部分参数 double realW = cap.Get(VideoCaptureProperties.FrameWidth); double realH = cap.Get(VideoCaptureProperties.FrameHeight); double realFps = cap.Get(VideoCaptureProperties.Fps);VideoWriter.FourCC(char, char, char, char)返回 int,Set接受 double,隐式转换没问题。不要用字符串重载,OpenCvSharp 不同小版本对FourCC类型的处理略有差异,四个字符的签名最稳。FrameWidth/FrameHeight单位是像素,Fps是目标帧率。最关键的是最后三行回读——设置不等于生效,摄像头只会给你它支持的模式,回读值才是后续封装参数的依据。我见过有人硬编码 30 写文件,实际只拿到 15fps,结果录出来的视频全程 2 倍速。
如果回读的 FPS 明显低于预期,先降分辨率试,比如 1280x720,再判断是不是带宽问题。还有一种情况是设备支持 MJPG 但驱动默认关闭,需要在设备管理器里手动开,这种就得在文档里写清楚。
3.2 读帧:Mat 复用与 Clone 的边界
VideoCapture.Read会复用内部缓冲区,同一个Mat实例多次读取后,旧数据会被覆盖。单线程「读一帧、处理一帧、写一帧」的写法没有任何问题,但一旦引入队列把帧交给别的线程,就必须Clone(),否则编码线程拿到的全是同一个对象,写出去是重复画面或者花屏。
using var frame = new Mat(); while (true) { if (!cap.Read(frame) || frame.Empty()) { // 读失败通常是设备被拔出或驱动重启 break; } // 跨线程传递必须 Clone,否则下一帧会覆盖同一块内存 Mat snapshot = frame.Clone(); try { queue.Add(snapshot); // queue 是 BlockingCollection<Mat> } catch (InvalidOperationException) { // 队列已关闭,释放本次拷贝避免泄漏 snapshot.Dispose(); break; } }Read返回 bool,为 false 一般代表流结束或设备异常,frame.Empty()是二次保险。Clone()会分配新的非托管内存,所以每一次分配都对应一次Dispose(),这个配对关系必须严格。BlockingCollection.Add在队列被CompleteAdding之后会抛InvalidOperationException,不 catch 就会在停止录制时冒出未处理异常,虽然不影响文件,但调试体验很差。参数上,队列容量一般设 3 到 5,太小起不到削峰作用,太大在编码卡顿时会堆积内存,1080p 一帧 BGR 大约 6MB,堆 30 帧就是 180MB,很容易被误判成内存泄漏。
3.3 生产消费者队列:采集线程与编码线程的解耦
采集和编码必须分线程。cap.Read的阻塞时间受设备影响,writer.Write的阻塞时间受编码器和磁盘影响,两者串在一个线程里,任何一个抖动都会拖累另一个,表现出来就是画面掉帧、编码器缓冲区断流。
using System.Collections.Concurrent; using System.Threading; // 有界队列:容量 4,满了让采集线程短暂等待,形成背压 var queue = new BlockingCollection<Mat>(boundedCapacity: 4); var captureThread = new Thread(() => { // 采集线程优先级略高,保证取帧节奏 Thread.CurrentThread.Priority = ThreadPriority.AboveNormal; // ... 采集循环,见上一节 }) { IsBackground = true, Name = "capture" }; var encodeThread = new Thread(() => { foreach (Mat mat in queue.GetConsumingEnumerable()) { try { writer.Write(mat); // 或推给 FFmpeg 管道 } finally { mat.Dispose(); // 谁消费谁释放 } } }) { IsBackground = true, Name = "encode" };boundedCapacity用的是背压思路:队列满时Add会阻塞采集线程,宁可让采集端等一会儿,也不让内存无限涨。GetConsumingEnumerable在CompleteAdding之后会把剩余元素消费完再退出,这个特性正好用于「停止录制时把缓冲区里最后几帧写完」,保证文件尾部不丢帧。finally里Dispose是硬性约定,消费方负责释放,生产方只管交出去,责任边界清晰才不会漏。停止顺序也要固定:先停采集线程、再CompleteAdding、最后Join编码线程,反过来会在队列关闭后继续Add而抛异常。
4. 编码落盘:VideoWriter 走 H264 的三条路与参数取舍
到了写文件这一步,可选路径其实有三条:VideoWriter直接调 OpenH264、把原始帧通过管道喂给外部 FFmpeg 进程、以及走系统自带的媒体 foundation 硬件编码。三条路各有适用场景,工程里我通常实现前两条,第一条为主,第二条兜底。
4.1 路径一:VideoWriter + OpenH264 直接写 mp4
这是代码量最少的做法,VideoWriter内部通过 videoio 模块找到 H264 编码器并封装成 mp4。前提是 OpenH264 动态库在位,且 FourCC 用对——avc1和H264在不同后端下行为不一样,avc1更接近 mp4 容器里实际存储的格式标识。
using OpenCvSharp; // 输出文件名带时间戳,避免分段录制时覆盖 string path = $"record_{DateTime.Now:yyyyMMdd_HHmmss}.mp4"; // avc1 表示 H264;帧率和尺寸必须与采集端回读值一致 using var writer = new VideoWriter( path, VideoWriter.FourCC('a', 'v', 'c', '1'), realFps, // 用回读的实际帧率 new Size((int)realW, (int)realH), isColor: true); if (!writer.IsOpened()) { // 常见原因:openh264 dll 缺失或位数不匹配 throw new InvalidOperationException("H264 编码器初始化失败,检查 openh264 动态库"); } // 逐帧写入,mat 尺寸必须与构造时一致 writer.Write(mat);FourCC('a','v','c','1')是 mp4 里 H264 的标准标识;realFps用回读值而不是常量,这是避免「录出来快进」的第一道保险。isColor: true表示输入是三通道 BGR,如果你的帧来自灰度相机要改成 false,否则颜色通道会错位。IsOpened()返回 true 只代表容器和编码器对象创建成功,不代表编码器真的能用——有些版本要等第一次Write才暴露问题,所以工程里最好在录制开始后校验文件大小是否在增长。
参数层面还可以进一步调,但VideoWriter暴露的接口有限,多数后端只让你设 FourCC、帧率、尺寸。想控制码率就得换路子,这也是为什么第二条路在某些场景下不可替代。
4.2 路径二:FFmpeg 管道拿原始帧自己编
当 OpenH264 不可用,或者需要更细的码率控制(CRF、preset、GOP 长度),我会把 BGR 原始帧通过标准输入喂给一个独立的编码进程。好处是编码参数完全可控、崩溃不影响主进程;代价是多一次内存拷贝和进程间通信开销。
using System.Diagnostics; // 启动 ffmpeg 子进程,从 stdin 读原始帧,输出 H264 mp4 var psi = new ProcessStartInfo { FileName = "ffmpeg.exe", Arguments = "-hide_banner -loglevel error " + "-f rawvideo -pix_fmt bgr24 " + $"-s {w}x{h} -r {fps} -i - " + "-c:v libx264 -preset veryfast -crf 23 " + "-pix_fmt yuv420p -g 60 -movflags +faststart " + $"\"{outputPath}\"", RedirectStandardInput = true, UseShellExecute = false, CreateNoWindow = true }; using var proc = Process.Start(psi); var stdin = proc.StandardInput.BaseStream; // 每帧按 BGR24 连续写入,长度必须是 w*h*3 byte[] buffer = new byte[w * h * 3]; // ... 把 Mat 数据拷进 buffer 后: stdin.Write(buffer, 0, buffer.Length);参数逐个说:-f rawvideo -pix_fmt bgr24声明输入是无头原始帧,格式与 OpenCV 的 BGR 通道序一致;-s和-r必须与采集端完全一致,错一个像素都会花屏;-i -表示从标准输入读;-c:v libx264选软件编码器;-preset veryfast是速度与压缩率的折中,录制场景通常够用;-crf 23控制质量,数值越小越清晰体积越大,18 到 28 是常用区间;-g 60设 GOP 长度,影响拖动进度条的粒度;-movflags +faststart把索引挪到文件头,网页端边下边播才正常。停止时要先关stdin让 ffmpeg 正常收尾,再WaitForExit,直接 Kill 会得到损坏的 mp4。
这条路的血泪经验是:stdin.Write是同步阻塞的,编码跟不上时会把调用线程卡住,所以必须放在独立的编码线程里,配合上一节的有界队列使用,形成完整的背压链路。
4.3 参数取舍:CRF、preset、GOP 与文件体积
选参数前先明确用途。监控留证看的是清晰度和可回溯性,体积不是首要;而桌面录屏、教学录制更在意体积和兼容性。给一组我常用的对照:
| 参数 | 取值 | 影响 | 适用场景 |
|---|---|---|---|
| CRF | 18 | 体积大、细节保真 | 留证、后期要再处理 |
| CRF | 23 | 体积与质量平衡 | 通用录制,默认选它 |
| preset | veryfast | CPU 占用低、压缩率一般 | 实时录制,机器性能一般 |
| preset | medium | 压缩率更好、CPU 高 | 离线转码,不追求实时 |
| GOP | 30 | 拖动粒度细、体积略大 | 需要频繁回看 |
| GOP | 60~120 | 体积更小 | 长时段连续录制 |
VideoWriter这条路调不了 CRF,只能靠后端默认值,所以对体积敏感的项目我会优先考虑管道方案。另外-pix_fmt yuv420p别省,很多播放器对yuv444p的 H264 兼容性很差,录出来在别的机器上黑屏,查半天以为是编码失败。
5. 避坑排查:录制链路上最常见的 5 个翻车现场
这一节按「现象 → 原因 → 解决」写,都是我在实际项目里遇到过的。录制类问题的麻烦之处在于失败往往是静默的:没有异常、没有日志,只有一个播不了或者不对劲的文件。
5.1 编码器初始化类问题
现象:IsOpened()返回 true,录制跑完,文件 0 字节或只有几 KB,播放器打不开。
原因:videoio 后端找到了封装器但找不到 H264 编码器实现,构建写入器时没有立刻失败,直到写第一帧才中断;另一种是 OpenH264 dll 位数与进程不匹配,加载被系统拒绝。
解决:先确认bin目录下 OpenH264 文件名里的位数与进程一致,再用writer.IsOpened()加「写入首帧后检查文件长度是否增长」双重校验。校验失败直接降级到 FFmpeg 管道方案,并记录一条明确日志,别让用户对着 0 字节文件猜。
5.2 时间戳与节奏类问题
现象:录十分钟,播放器显示时长五分钟,画面明显快进;或者拖动进度条时画面跳变。
原因:写入器用固定帧率封装,但采集端实际帧率低于设定值,中间丢的帧没有对应的时间戳补偿,播放器只能按固定间隔播放,于是整体加速。
解决:构造写入器时用回读的实际 FPS,而不是硬编码 30;对丢帧严重的场景,按「实际到达时间」重排时间戳,或者干脆降低分辨率保证帧率稳定。管道方案里可以用-vsync cfr配合正确帧率参数,让 ffmpeg 按恒定帧率补齐。
5.3 资源与内存类问题
现象:录十几分钟后内存持续上涨,进程句柄数不断增加,最终卡死或崩溃。
原因:Mat跨线程传递时没有Clone就入队,或者入队后没在消费端Dispose;另外预览窗口每帧新建Bitmap而没有释放,GDI 对象堆积。
解决:严格「谁 Clone 谁负责、谁消费谁释放」;用using包住所有临时Mat;预览刷新走节流,比如每三帧刷一次 UI,而不是每帧都转BitmapSource。上线前用任务管理器盯一遍句柄数曲线,比事后查 dump 省事得多。
5.4 设备与驱动类问题
现象:设置 1920x1080 后回读只有 640x480,或者第一次能开、第二次提示设备被占用。
原因:参数设置顺序不对导致带宽协商失败;或者上一次会话的VideoCapture没有释放,USB 设备句柄还挂在进程上。
解决:固定「FourCC → 分辨率 → 帧率」的设置顺序,每次设置后回读校验;VideoCapture一律using或显式Dispose,异常路径也要保证释放。热插拔场景加设备重连逻辑,别指望拔了再插还能继续读。
5.5 文件与磁盘类问题
现象:录制中途断电或强制结束进程,文件损坏完全无法恢复。
原因:mp4 的索引信息在文件尾部,进程被强杀时索引没写入,文件头看起来正常但无法定位数据。
解决:分段录制,每段几分钟,用管道方案时加-movflags +faststart,并定期调用刷新;关键场景同时写一份裸 H264 流作为备份,即使 mp4 损坏也能用工具重新封装。后悔药这种东西,做录制的时候最好提前备好。
6. 二次开发:加音频、分段与推流的改造点
录制跑通之后,二次开发的需求基本集中在三处。第一处是加音频:用系统回环或者麦克风采集拿到 PCM,按采样率换算成与视频帧对齐的时间戳,再在封装阶段合流。如果走 FFmpeg 管道,最省事的做法是开第二条管道写 PCM,最后用一次-c:v copy -c:a aac合并,避免实时混流带来的复杂度。
第二处是分段录制。别在一个VideoWriter上硬切,而是在Recording层维护会话状态机,到时长阈值就停止当前写入器、CompleteAdding编码线程、开新文件、重建队列和线程。切换那几百毫秒的间隙会丢帧,对连续性要求高的场景要双缓冲,新的写入器初始化成功后再切流。
第三处是推流。把输出从本地文件换成 RTMP 地址,管道方案几乎不用改,只把输出参数从路径换成-f flv rtmp://...,编码参数保持libx264加-tune zerolatency。验证方法我一般固定三步:用ffprobe检查输出流的编码格式与帧率是否与预期一致;用秒表加日志统计每分钟实际写入帧数;连续跑两小时看内存和句柄曲线是否平稳。这三步走完,基本能覆盖绝大多数线上问题。
改造时有个细节值得提前设计:把编码器抽象成接口,让 OpenH264 和 FFmpeg 管道可以运行时切换,配置里留一个开关。我吃过一次亏,客户现场那台机器上 OpenH264 死活加载不了,临时改代码重发版本,折腾到半夜。从那以后我每次做录制模块,都会把编码后端做成可替换的,并且默认带一条兜底路径。希望这些经验能帮到你,少走一段我走过的弯路。
本文还有配套的精品资源,点击获取