简介:本资源面向具备一定C#基础的开发者,聚焦于使用OpenCvSharp读取RTSP视频流并录制为MP4文件这一常见需求,提供可直接运行的完整Demo工程,帮助解决网络摄像头接入、实时流解码与本地视频保存等实际问题,适用于安防监控、视频采集与流媒体处理等场景。压缩包共261个文件,约161.67MB,包含63个dll依赖库、9个cs源码文件、1个sln解决方案与1个csproj工程文件,另有xml文档、nupkg包、config配置、png截图及1个mp4示例视频等,覆盖编译运行所需的完整依赖与工程结构。目前已有1106人学习下载。读者可获取一套开箱即用的RTSP拉流录制方案,直接参考源码理解OpenCvSharp的VideoCapture与VideoWriter用法,快速移植到自己的项目中,省去环境搭建与依赖排查的时间。
1. 从一路 RTSP 到本地 MP4:为什么我最终选了 OpenCvSharp
很多做 C# 上位机、视频监控客户端或者边缘盒子的同行,第一次接到“把摄像头 RTSP 流录成 MP4 存本地”这个需求时,第一反应往往是去找 FFmpeg 命令行或者某个封装库。我最早也是这么干的,结果在客户现场被现实按在地上摩擦:命令行进程不好管、异常退出后文件损坏、多路并发时 CPU 直接飙满。后来我把方案换成了 OpenCvSharp,用 VideoCapture 拉流、VideoWriter 写文件,整个链路在纯 C# 进程内闭环,部署时只需要带上几个原生 DLL,现场排错也直观得多。这个标题里的“读取 RTSP 流录制 MP4”,本质上就是解决三件事:稳定地把网络流拉下来、按指定帧率和分辨率重新编码、在磁盘上生成一个能正常播放的 MP4 文件。它适合做安防客户端、工业视觉记录、远程巡检存证的开发者,尤其是那些不想在项目里塞一整套转码服务、只想用托管代码搞定录制的场景。下面我按自己踩过的坑,把选型、参数、代码和排查一条条讲清楚。
2. 拉流与写文件的核心链路:VideoCapture 和 VideoWriter 怎么配合
2.1 为什么不用 FFmpeg 命令行而用 OpenCvSharp
常见做法是起一个ffmpeg.exe子进程,把 RTSP 地址当输入、MP4 路径当输出。这个方案在单路、短时间录制时没问题,但一旦进入工程化就会暴露几个硬伤。第一,进程管理麻烦,你要处理标准输出重定向、超时杀死、僵尸进程,稍不注意就留下一堆后台进程。第二,异常恢复差,网络抖动导致 FFmpeg 退出时,正在写的 MP4 的 moov box 可能没写完,文件直接报废。第三,参数传递靠字符串拼接,路径里有空格或中文就容易翻车。OpenCvSharp 的优势在于它把拉流和解码封装在VideoCapture里,编码封装在VideoWriter里,全部在同一个 .NET 进程内完成,异常可以用 try-catch 捕获,线程也好控制。代价是它底层仍然依赖 FFmpeg 的 native 库,所以部署时要确保OpenCvSharpExtern.dll和相关的opencv_videoio_ffmpeg*.dll在输出目录里。我一般会把这些 native 依赖放在runtimes/win-x64/native下,用 NuGet 包OpenCvSharp4和OpenCvSharp4.runtime.win就能自动带出来。
2.2 最小可运行代码:从 RTSP 到 MP4 的完整闭环
下面这段代码是我在多个项目里反复用过的骨架,去掉了业务逻辑,只保留拉流和录制。注意它用的是VideoCapture的Open重载,传入 RTSP 地址和VideoCaptureAPIs.FFMPEG,强制走 FFmpeg 后端,避免 Windows 上默认走 MSMF 导致 RTSP 打不开。
using OpenCvSharp; using System; using System.Threading; class RtspRecorder { static void Main() { string rtspUrl = "rtsp://user:pass@192.168.1.100:554/stream1"; string outputPath = @"D:\record\cam01_20250101_120000.mp4"; // 强制使用 FFMPEG 后端,RTSP 在 Windows 上必须显式指定 using var capture = new VideoCapture(rtspUrl, VideoCaptureAPIs.FFMPEG); if (!capture.IsOpened()) { Console.WriteLine("拉流失败,检查地址、账号密码或网络"); return; } // 读取源流的帧率和分辨率,作为写文件的基准 double fps = capture.Fps; int width = (int)capture.FrameWidth; int height = (int)capture.FrameHeight; // 有些 RTSP 源不返回 fps,给一个兜底值 if (fps <= 0 || fps > 60) fps = 25; // 用 MP4V 编码,FourCC 是 'mp4v' var fourcc = FourCC.FromString("mp4v"); using var writer = new VideoWriter(outputPath, fourcc, fps, new Size(width, height)); if (!writer.IsOpened()) { Console.WriteLine("VideoWriter 打开失败,检查编码器或路径权限"); return; } using var frame = new Mat(); int frameCount = 0; var startTime = DateTime.Now; while (true) { // 读取一帧,超时或断流时 Read 返回 false if (!capture.Read(frame) || frame.Empty()) { Console.WriteLine("读帧失败,可能断流,尝试重连"); break; } writer.Write(frame); frameCount++; // 每 100 帧打印一次进度,方便现场观察 if (frameCount % 100 == 0) { double elapsed = (DateTime.Now - startTime).TotalSeconds; Console.WriteLine($"已录制 {frameCount} 帧,耗时 {elapsed:F1}s,实际帧率 {frameCount / elapsed:F1}"); } // 按 Esc 退出,实际项目里用 CancellationToken if (Cv2.WaitKey(1) == 27) break; } // 释放 writer 会写入 MP4 的 moov box,这一步不能省 writer.Release(); capture.Release(); Console.WriteLine($"录制结束,共 {frameCount} 帧,文件:{outputPath}"); } }逻辑上分四步:打开流、读元数据、循环读帧写帧、释放资源。参数方面,VideoCaptureAPIs.FFMPEG是关键,不指定的话在 Windows 上可能走 MSMF,RTSP 支持很差。FourCC.FromString("mp4v")对应 MPEG-4 Part 2 编码,兼容性好但压缩率一般;如果追求更小体积,可以换成"avc1"走 H.264,但需要 OpenCV 编译时带 openh264 或系统里有对应编码器,否则IsOpened()会返回 false。fps和Size必须和源流一致或成比例,否则写出来的文件播放速度会异常。writer.Release()必须调用,它负责把 MP4 的索引写进去,少了这一步文件可能无法播放。
2.3 帧率、分辨率与编码器的参数取舍
实际项目里最容易被忽略的是帧率匹配。RTSP 源通常标称 25fps,但网络抖动时实际到达的帧可能只有 20fps 甚至更低。如果你用 25fps 写文件,而实际读帧慢,录出来的视频会变成快放。我的做法是:先按源 fps 写,同时在循环里统计实际帧率,如果偏差超过 20%,就动态调整写入策略——要么丢帧保持时间轴,要么改用可变帧率容器(但 MP4 对 VFR 支持一般)。更稳妥的方案是固定按源 fps 写,读不到帧时用上一帧填充,保证时间轴连续。分辨率方面,如果源是 1080p 而你想省磁盘,可以在writer.Write之前用Cv2.Resize缩到 720p,但注意缩放会增加 CPU 开销,多路并发时要算好余量。编码器选择上,mp4v是 OpenCV 内置支持最稳的,avc1和h264依赖外部库,xvid在 MP4 容器里兼容性差。我一般先用mp4v跑通,确认业务逻辑没问题后再评估是否换 H.264。
3. 多路并发与断流重连:工程化必须处理的三个问题
3.1 多路 RTSP 同时录制的线程模型
单路录制跑通后,下一步就是多路。常见做法是每路开一个独立线程,每个线程里跑一个VideoCapture和一个VideoWriter。这里有个坑:OpenCV 的VideoCapture不是线程安全的,但不同实例之间互不干扰,所以每路一个实例没问题。线程模型我推荐用Task.Run配合CancellationToken,而不是裸Thread,这样退出时能统一取消。下面是一个多路录制的调度骨架,重点看取消令牌和异常隔离。
using OpenCvSharp; using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; class MultiCamRecorder { static async Task Main() { var cameras = new[] { new { Name = "cam01", Url = "rtsp://192.168.1.101:554/stream1" }, new { Name = "cam02", Url = "rtsp://192.168.1.102:554/stream1" }, new { Name = "cam03", Url = "rtsp://192.168.1.103:554/stream1" } }; var cts = new CancellationTokenSource(); var tasks = new List<Task>(); foreach (var cam in cameras) { string url = cam.Url; string name = cam.Name; // 每路一个独立任务,异常不会互相影响 tasks.Add(Task.Run(() => RecordOne(url, name, cts.Token), cts.Token)); } // 主线程等待,实际项目里由 UI 或服务控制 Console.WriteLine("按 Enter 停止所有录制"); Console.ReadLine(); cts.Cancel(); try { await Task.WhenAll(tasks); } catch (OperationCanceledException) { } Console.WriteLine("全部停止"); } static void RecordOne(string url, string name, CancellationToken token) { using var capture = new VideoCapture(url, VideoCaptureAPIs.FFMPEG); if (!capture.IsOpened()) { Console.WriteLine($"[{name}] 打开失败"); return; } double fps = capture.Fps > 0 ? capture.Fps : 25; int w = (int)capture.FrameWidth; int h = (int)capture.FrameHeight; string path = $@"D:\record\{name}_{DateTime.Now:yyyyMMdd_HHmmss}.mp4"; using var writer = new VideoWriter(path, FourCC.FromString("mp4v"), fps, new Size(w, h)); using var frame = new Mat(); while (!token.IsCancellationRequested) { if (!capture.Read(frame) || frame.Empty()) { Console.WriteLine($"[{name}] 断流,等待重连"); Thread.Sleep(2000); // 重连逻辑:释放后重新 Open capture.Release(); if (!capture.Open(url, VideoCaptureAPIs.FFMPEG)) { Console.WriteLine($"[{name}] 重连失败,退出"); break; } continue; } writer.Write(frame); } writer.Release(); capture.Release(); Console.WriteLine($"[{name}] 录制结束:{path}"); } }这里的关键点是每路任务独立捕获异常,一路断流不影响其他路。CancellationToken用来优雅停止,避免强制杀线程导致 writer 没释放、文件损坏。重连逻辑放在读帧失败之后,先Release再Open,不要试图在同一个 capture 上反复Read,那样大概率一直失败。
3.2 断流重连与文件完整性保护
断流是 RTSP 录制的常态,尤其是无线网络或跨网段场景。我的血泪经验是:不要等程序崩溃了才处理,要在读帧失败的第一时间就进入重连流程。重连时有两个选择:一是继续写同一个文件,二是新开一个文件。继续写同一个文件的问题是,如果断流时间较长,时间轴会出现跳跃,播放器可能花屏;新开文件则会产生很多小片段,后期需要合并。我一般根据业务定:安防存证要求连续,就继续写同一文件,但断流期间用上一帧填充,保持时间轴;如果只是巡检记录,就新开文件,文件名带时间戳,简单可靠。另外,VideoWriter在写入过程中如果进程被强杀,MP4 的 moov box 不会写入,文件基本报废。所以我在关键项目里会加一个看门狗线程,定期检查 writer 是否还活着,或者用try-finally确保Release一定执行。
3.3 磁盘写入与缓冲策略
多路 1080p 录制时,磁盘 I/O 很容易成为瓶颈。我遇到过 4 路同时写同一个机械硬盘,结果帧率从 25 掉到 8,画面卡顿。解决办法有两个:一是把文件分散到不同物理磁盘,二是调整VideoWriter的缓冲。OpenCV 的VideoWriter没有直接暴露缓冲参数,但可以通过VideoWriterProperties设置一些底层选项,比如VideoWriterProperties.FrameBytes或VideoWriterProperties.Quality。更实际的做法是控制写入频率,不要每读一帧就写一帧,而是攒几帧批量写,但这样会增加内存占用和延迟。我一般会先测磁盘顺序写入速度,确保总码率不超过磁盘能力的 70%。另外,MP4 文件不要放在网络共享盘上,SMB 的延迟波动会让 writer 频繁超时。
4. 避坑与排查:RTSP 录制 MP4 最常见的五个翻车点
4.1 拉流成功但写出的 MP4 无法播放
现象:VideoWriter.IsOpened()返回 true,录制过程也没报错,但生成的 MP4 用播放器打开要么黑屏,要么提示文件损坏。原因通常是writer.Release()没有被调用,或者进程异常退出导致 MP4 的 moov box 没写入。MP4 的索引信息是在文件末尾写入的,如果写入过程中断,文件就废了。解决方法是把writer.Release()放在finally块里,并且避免在录制线程里做可能抛异常的操作。另外,检查FourCC是否被系统支持,mp4v一般没问题,但如果你用了avc1而系统没有对应编码器,IsOpened()可能仍然返回 true,但实际写入的是空帧。
4.2 录制文件播放速度异常,像快进或慢放
现象:录了 10 分钟,播放器显示只有 2 分钟,或者画面动作明显加快。原因几乎都是fps参数和实际读帧速率不匹配。比如源流标称 25fps,但网络实际只能拉到 10fps,你用 25fps 写文件,播放器就按 25fps 播,时间被压缩。解决方法是先探测实际帧率:在正式录制前跑一个 5 秒的采样,统计capture.Read的成功次数,算出平均帧率,再用这个值初始化VideoWriter。如果实际帧率波动大,就固定用源 fps,读不到帧时重复写上一帧,保证时间轴连续。
4.3 多路录制时 CPU 占用过高
现象:单路录制 CPU 占用 5%,开到 4 路直接飙到 90%,画面开始丢帧。原因通常是解码和编码都在 CPU 上跑,OpenCV 默认不使用硬件加速。解决方法是:第一,降低分辨率,在writer.Write之前用Cv2.Resize缩到 720p 或 480p;第二,如果源支持,直接拉子码流而不是主码流;第三,检查是否开启了不必要的色彩空间转换,比如capture.Read默认输出 BGR,如果你不需要处理,直接写就行,不要转灰度再转回来。另外,VideoWriter的编码器选择也有影响,mp4v比avc1省 CPU,但压缩率低。
4.4 RTSP 地址带特殊字符导致打开失败
现象:同样的地址在 VLC 里能播,在 OpenCvSharp 里IsOpened()返回 false。原因通常是地址里的密码包含@、:、/等字符,没有做 URL 编码。比如密码是p@ss:word,直接拼进rtsp://user:p@ss:word@ip:554/stream会被解析成错误的 host。解决方法是先对用户名和密码做Uri.EscapeDataString,再拼接。另外,有些摄像头要求 RTSP over TCP,可以在地址后面加?tcp或者设置capture.Set(VideoCaptureProperties.BufferSize, 1)来减少缓冲。
4.5 录制一段时间后程序内存持续增长
现象:程序跑几个小时后内存从 100MB 涨到 1GB 以上,最后 OOM。原因通常是Mat对象没有释放,或者VideoCapture的缓冲队列堆积。OpenCvSharp 的Mat实现了IDisposable,但如果你在循环里不断new Mat()而不using,GC 来不及回收就会涨。解决方法是把Mat声明在循环外面,用using包住,或者手动调用Dispose。另外,VideoCapture内部有帧缓冲,如果读帧速度跟不上拉流速度,缓冲会越来越大。可以设置capture.Set(VideoCaptureProperties.BufferSize, 1)把缓冲降到最低,代价是更容易丢帧,但内存稳定。
5. 进阶技巧:用时间戳切片和动态码率控制把录制做得更专业
5.1 按时间切片录制,避免单文件过大
安防场景通常要求每 10 分钟或 30 分钟切一个文件,方便检索和备份。实现思路是在录制循环里检查当前文件已写入的时长,超过阈值就writer.Release(),然后用新的时间戳文件名重新创建VideoWriter。注意切片时不要中断VideoCapture,只换 writer 即可。下面是一个切片逻辑的片段,关键在lastSwitchTime和segmentSeconds的配合。
using OpenCvSharp; using System; class SegmentedRecorder { static void Main() { string url = "rtsp://192.168.1.100:554/stream1"; int segmentSeconds = 600; // 每 10 分钟切一个文件 string outputDir = @"D:\record"; using var capture = new VideoCapture(url, VideoCaptureAPIs.FFMPEG); if (!capture.IsOpened()) return; double fps = capture.Fps > 0 ? capture.Fps : 25; int w = (int)capture.FrameWidth; int h = (int)capture.FrameHeight; VideoWriter writer = null; DateTime segmentStart = DateTime.Now; string currentPath = ""; using var frame = new Mat(); while (true) { if (!capture.Read(frame) || frame.Empty()) break; // 到达切片时间或 writer 未初始化时,创建新文件 if (writer == null || (DateTime.Now - segmentStart).TotalSeconds >= segmentSeconds) { writer?.Release(); currentPath = System.IO.Path.Combine(outputDir, $"cam01_{DateTime.Now:yyyyMMdd_HHmmss}.mp4"); writer = new VideoWriter(currentPath, FourCC.FromString("mp4v"), fps, new Size(w, h)); segmentStart = DateTime.Now; Console.WriteLine($"新切片:{currentPath}"); } writer.Write(frame); } writer?.Release(); capture.Release(); } }这里要注意,切片时writer.Release()会写入 moov box,所以每个切片文件都是完整可播放的。segmentSeconds根据磁盘空间和检索需求定,我一般用 600 秒,太小会产生大量文件,太大则单个文件几个 GB,备份不方便。
5.2 动态码率与画质平衡
OpenCV 的VideoWriter对码率的控制比较弱,mp4v编码器主要靠Quality属性调节,范围 0-100,默认 95。实际测试下来,1080p 25fps 用默认质量,码率大约 4-6 Mbps,一小时约 2GB。如果磁盘紧张,可以把质量降到 70,码率能减半,但快速运动画面会出现块效应。更精细的控制需要换用 H.264 编码器,通过VideoWriterProperties设置Bitrate,但前提是 OpenCV 编译时带了对应支持。我的建议是:先用mp4v加质量调节跑通,如果客户对体积有硬要求,再考虑引入外部编码器或转码步骤。另外,录制时不要同时做复杂图像处理,比如每帧都跑目标检测,那样 CPU 抢不过来,写帧会延迟,最终影响文件时间轴。
5.3 验证录制文件是否完整的三个方法
录完一个文件后,怎么确认它没问题?我一般用三个方法交叉验证。第一,用VideoCapture重新打开录制的 MP4,读取FrameCount和Fps,算出的时长应该和实际录制时长接近,偏差超过 10% 就有问题。第二,用播放器拖到文件末尾,看是否能正常播放到最后一秒,如果拖到后面卡死或花屏,说明 moov box 或索引有问题。第三,检查文件大小,按码率估算的预期大小和实际大小对比,如果明显偏小,可能是丢帧严重。下面这段代码就是用来做第一项验证的。
using OpenCvSharp; class VerifyMp4 { static void Main() { string path = @"D:\record\cam01_20250101_120000.mp4"; using var cap = new VideoCapture(path); if (!cap.IsOpened()) { Console.WriteLine("文件无法打开"); return; } double fps = cap.Fps; int frames = (int)cap.FrameCount; double duration = frames / fps; Console.WriteLine($"帧数:{frames},帧率:{fps:F2},时长:{duration:F1}s"); Console.WriteLine($"分辨率:{cap.FrameWidth}x{cap.FrameHeight}"); } }如果FrameCount返回 0 或负数,说明索引没写好,文件基本不可用。这时候只能靠录制时的日志来定位,看是Release没调用还是写入过程中断了。
5.4 我踩过的最深的一个坑
早期做多路录制时,我为了省事,把VideoWriter的创建放在了一个公共方法里,结果多路并发时偶尔出现文件互相覆盖。排查了很久才发现,文件名用了DateTime.Now.ToString("yyyyMMdd_HHmmss"),两路在同一秒启动时时间戳完全一样,后创建的 writer 把前面的文件覆盖了。后来我在文件名里加了摄像头 ID 和毫秒,问题才消失。这个教训让我养成了一个习惯:凡是生成文件名,一定要带唯一标识,不要只靠时间戳。另外,VideoWriter在文件已存在时的行为是覆盖,不会报错,所以覆盖是静默发生的,很难发现。希望帮到你。
本文还有配套的精品资源,点击获取