ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于C#和Emgu.CV的RTSP视频流分段录制实现与踩坑总结

基于C#和Emgu.CV的RTSP视频流分段录制实现与踩坑总结 简介RTSP作为网络视频流传输的标准协议在安防监控、工业视觉等场景中广泛应用。C#开发者面对实时视频流处理时常借助Emgu.CV这一OpenCV的托管封装库在.NET环境下直接操作帧图像数据。其核心价值在于VideoCapture与VideoWriter组件提供了从拉流到编码落盘的完整链路同时支持对每一帧进行画框、OSD叠加等二次处理满足AI检测、事件标记等复杂需求。针对长时间录制场景分段保存策略能有效降低单文件损坏风险并提升检索效率。本文基于实际项目经验详细介绍使用C#与Emgu.CV实现RTSP拉流、按时间分段录制视频文件的完整流程涵盖编码参数选择、切割计时、缓冲队列优化及断线重连等关键环节为类似需求提供可落地的工程参考。 做安防监控或者工业视觉这块的兄弟应该没少碰RTSP拉流的需求。最近有个项目要接十几个海康摄像头把实时画面录下来按片段存盘方便后续回溯和二次处理。我直接用C#配合Emgu.CV把这个活干了。这篇文章就把整个实现过程、分段保存的细节、还有我踩过的几个坑全部摊开讲从拉流到落盘一次说清。1. 整体设计与技术方案选型1.1 为什么用Emgu.CV而不是其他方案先说结论如果你只是想把RTSP流原封不动存成文件那直接用FFmpeg命令行最省事。但如果你跟我一样需要在录制的同时做画框、叠加文字、抽帧分析或者后续要接图像处理算法那必须在代码里拿到每一帧图像数据。Emgu.CV是OpenCV的C#封装能让你在托管环境里直接操作Mat对象这就意味着你可以对每一帧做任何你想做的事再决定怎么写进视频文件。有人可能问AForge.NET不也能抓RTSP吗我之前也用过AForge简单场景没问题但它的底层对H.265编码的摄像头支持很弱而且ARM平台跑起来问题不少。Emgu.CV内部的VideoCapture对应的是OpenCV的FFmpeg后端兼容性好很多解码H.264/H.265都行。这年头海康、大华的新摄像头很多默认就是H.265选型这一步就得把这个考虑进去。1.2 分段保存的核心需求拆解分段保存这个需求看起来就是“录一段关掉再开新的”但真做起来有几件事绕不开。第一为什么不能一个文件录到底长时间录制单个文件一旦中途断电或者程序崩溃这个文件大概率就废了前面的素材全丢。分段之后最坏情况只是丢当前这一小段。第二视频文件方便检索按时间段切好回溯某个时间段直接找对应文件名就行。第三后续上传或者转存也更灵活。分段时长怎么定我这边用的是5分钟一段。太短了文件数量太多频繁开关VideoWriter会有性能损耗太长了又失去分段的优势。有人喜欢1小时一段看业务场景如果是长时间无人值守监控我建议10到15分钟比较平衡。1.3 整体流程框架整个程序的流程基本是这样初始化VideoCapture传入RTSP地址循环读取每一帧Mat图像判断当前镜头时间是否达到分割时长到了就释放当前VideoWriter按时间戳创建新文件没到就继续往上写这个流程听起来简单难点都在细节上怎么控制切割精度、怎么避免丢帧、怎么处理断线重连、怎么处理编码器参数设置。下面几个章节一个一个拆开讲。2. 环境搭建与Emgu.CV关键配置2.1 NuGet包安装与版本选择我用的是.NET Framework 4.7.2因为项目里还有别的老依赖。你要是新项目直接用.NET 6/8也行Emgu.CV对现代.NET支持得也还可以。通过NuGet搜索Emgu.CV会出来一堆包核心就是几个Emgu.CVEmgu.CV.BitmapEmgu.CV.runtime.windows注意Emgu.CV 4.x版本之后运行时DLL是独立打包的一定记得把Emgu.CV.runtime.windows也装上。我见过好多次有人只装了主包运行时报Emgu.CV.CvInvoke类型初始化失败其实就是少了运行时包。还有一个特别容易被忽略的问题Emgu.CV依赖的OpenCV原生DLL需要VC运行库。如果目标机器是精简版Windows建议把vc_redist.x64.exe一起带上否则在客户机器上部署时大概率会崩。2.2 x86还是x64提前想清楚OpenCV原生库对位数要求很严格x64程序必须用x64的DLLx86同理。现在摄像头动辄400万、800万像素内存占用不小建议直接x64。另外如果你的程序里装了其他也带OpenCV的NuGet包注意版本冲突。这个问题我后面在常见问题里单独说。2.3 初始化VideoCapture并打开RTSP流Emgu.CV里拉流很简单一句话就能建好捕获对象VideoCapture capture new VideoCapture(rtspUrl); if (!capture.IsOpened()) { // 拉流失败处理重连逻辑 }RTSP地址的格式要注意常见的有两种海康rtsp://用户名:密码IP:554/Streaming/Channels/101大华rtsp://用户名:密码IP:554/cam/realmonitor?channel1subtype0海康那个101是主码流102是子码流。调试的时候我建议先用子码流测通道能不能通通了再切主码流毕竟主码流带宽和解码压力都大。测试阶段如果不想用自己的摄像头也可以用一些公开的RTSP测试地址但要确保网络通而且别用内网地址去测外网服务。2.4 读取帧与Mat基础操作初始化和实时读取的代码长这样using (VideoCapture capture new VideoCapture(rtsp://admin:password192.168.1.64:554/Streaming/Channels/101)) { if (!capture.IsOpened()) { Console.WriteLine(拉流失败); return; } Mat frame new Mat(); while (true) { bool readOk capture.Read(frame); if (!readOk || frame.IsEmpty) { // 可能断流了 break; } // 在这里可以画框、OSD、抽帧分析 // 比如 CvInvoke.Rectangle(frame, rect, new Bgr(Color.Red).MCvScalar, 2); // 然后写入VideoWriter writer.Write(frame); } }这里有个性能点capture.Read(frame)是阻塞式的默认按照视频流的帧率来返回。如果源是25fps这个函数大概40ms返回一帧。如果你要在这个循环里做耗时比较长的图像处理会导致实际写入视频文件的帧率低于源帧率表现出来就是录像看起来“一顿一顿”的。这个问题后面第4章会专门讨论。3. 分段保存的核心实现与源码3.1 VideoWriter的四参数详解VideoWriter的构造函数包括文件名、编码格式、帧率、尺寸这几个参数。帧率建议从capture.Get(CapProp.Fps)读取而不是写死不然帧率不匹配会导致录像时长和实际时间对不上。double fps capture.Get(Emgu.CV.CvEnum.CapProp.Fps); int width capture.Get(Emgu.CV.CvEnum.CapProp.FrameWidth); int height capture.Get(Emgu.CV.CvEnum.CapProp.FrameHeight); VideoWriter writer new VideoWriter( filePath, VideoWriter.Fourcc(M, J, P, G), fps, new Size(width, height), true);Fourcc编码格式这里有个说法。OpenCV在不同平台和编译选项下支持的编码器不一样。我常用的两种MJPG兼容性最好基本任何OpenCV版本都能写缺点是文件体积大。适合临时调试。XVID压缩率好一点但需要系统里有对应编码器。如果你要H.264编码Emgu.CV在Windows上能不能写取决于你的OpenCV跑的是不是带FFmpeg的完整版。我用默认的runtime.windows包写H264有时候会失败最后干脆MJPG加XVID双保险。如果是存档用途我个人建议用XVID或者干脆后期再转码不然MJPG那体积能把你磁盘写爆。3.2 分段切割逻辑停表比时间戳靠谱分段切割的常用思路是每次记录一个起始时间DateTime startTime每次循环检查DateTime.Now - startTime是否超过设定时长。这个方案在帧率不稳定的情况下会有偏差。因为你每次循环之间的间隔不是精确的40ms可能偶尔丢帧卡顿一下累计起来切割时间就飘了。我更推荐用Stopwatch来做分段计时精度高而且不受系统时间调整影响Stopwatch segmentTimer new Stopwatch(); segmentTimer.Start(); DateTime segmentStartTime DateTime.Now; while (capture.Read(frame)) { // 每隔固定帧数检查一次避免每次都查耗时 if (segmentTimer.ElapsedMilliseconds segmentDurationMs) { segmentTimer.Restart(); segmentStartTime DateTime.Now; ReleaseWriter(writer); writer CreateNewWriter(segmentStartTime); } writer.Write(frame); }切割之后创建新文件的命名我习惯用yyyyMMdd_HHmmss作为主标识再补一个毫秒的尾号防止同一秒内多次切换导致文件名冲突string fileName $record_{segmentStartTime:yyyyMMdd_HHmmss_fff}.avi;顺带提一嘴文件后缀要和编码格式匹配MJPG配.aviXVID配.avi或者.mkv都行但别对着MP4容器写MJPG有些播放器会识别不出来。3.3 完整的视频录制类框架我把核心逻辑封装成一个类下面这个结构可以当模板直接用。这个类处理了创建、写入、轮转、释放的完整生命周期。public class SegmentVideoRecorder : IDisposable { private VideoCapture _capture; private VideoWriter _writer; private Stopwatch _segmentTimer; private readonly string _rtspUrl; private readonly int _segmentDurationMs; private readonly string _outputDir; private DateTime _segmentStartTime; public SegmentVideoRecorder(string rtspUrl, string outputDir, int segmentDurationMs) { _rtspUrl rtspUrl; _outputDir outputDir; _segmentDurationMs segmentDurationMs; _segmentTimer new Stopwatch(); } public void Start() { _capture new VideoCapture(_rtspUrl); if (!_capture.IsOpened()) { throw new Exception(无法打开RTSP流); } _segmentStartTime DateTime.Now; _writer CreateNewWriter(_segmentStartTime); _segmentTimer.Restart(); Mat frame new Mat(); while (_capture.Read(frame)) { if (!frame.IsEmpty) { if (_segmentTimer.ElapsedMilliseconds _segmentDurationMs) { RotateSegment(); } _writer.Write(frame); } } } private void RotateSegment() { _writer?.Dispose(); _segmentTimer.Restart(); _segmentStartTime DateTime.Now; _writer CreateNewWriter(_segmentStartTime); } private VideoWriter CreateNewWriter(DateTime time) { var fps _capture.Get(Emgu.CV.CvEnum.CapProp.Fps); var width (int)_capture.Get(Emgu.CV.CvEnum.CapProp.FrameWidth); var height (int)_capture.Get(Emgu.CV.CvEnum.CapProp.FrameHeight); var filePath Path.Combine(_outputDir, $record_{time:yyyyMMdd_HHmmss_fff}.avi); return new VideoWriter( filePath, VideoWriter.Fourcc(X, V, I, D), fps, new Size(width, height), true); } public void Dispose() { _writer?.Dispose(); _capture?.Dispose(); } }这个类只是单次连接的基本款。实际项目里还要加断线重连和异常保护下面“常见问题与排查”那一章会说。3.4 分段时刻的“最后一帧”处理切割逻辑看起来是“到点就切”但有个细节你是在写当前帧之前检查时间也就是说实际上你这一帧会写到新的文件里而上一个文件会在写入上一帧时被关闭。乍一看没问题但精确到帧级别上一个文件的时长可能比设定值短几十毫秒。这正常不用管它。反过来的问题要注意如果你的处理和写入耗时比较久比如一帧处理了500ms那么切割会变得不及时切割点可能拖到下一帧才执行。这种情况下要监控单帧处理耗时时长否则分段会变成“不稳定的时长”。我自己写的时控逻辑是每10帧检查一次时间而不是每帧都检查这样能减少Stopwatch查询频率对性能友好一点。切到下一段时当前帧会被写入新文件旧文件可能比设定时间短一点点这个可以接受。4. 流畅度优化与缓冲池设计4.1 为什么直接读写会卡顿如果你现在的代码是“读一帧处理一帧写一帧”在高分辨率高帧率下很容易出现卡顿。原因不在于Emgu.CV本身而是这几步都在一个线程里串行执行任何一步慢了都会拖累整体节奏。尤其是从RTSP拉流时网络抖动会导致某一帧迟迟到不了capture.Read就这么堵住了后续的写入也跟着停。表现就是录像文件的帧率忽高忽低严重的时候画面会卡住好几秒。解决思路是把“拉流”和“处理写入”解耦用生产者-消费者模式。拉流线程只负责把帧塞进队列处理线程从队列里取帧再做画框和写入。这样就算网络抖动也不至于让写入线程干等。缓冲队列能“熨平”短时间的波动。4.2 简单的缓冲队列实现BlockingCollectionMat frameQueue new BlockingCollectionMat(100); // 拉流线程 Task.Run(() { while (capture.Read(frame)) { if (!frame.IsEmpty) { // 注意这里要复制一份或者用引用计数管理 frameQueue.Add(frame.Clone()); } } frameQueue.CompleteAdding(); }); // 写入线程 Task.Run(() { foreach (var mat in frameQueue.GetConsumingEnumerable()) { writer.Write(mat); mat.Dispose(); } });队列容量不能设太大。100帧大概就是4秒的缓冲太小了起不到平滑作用太大内存扛不住而且会导致录像延迟越来越高。如果你的业务对实时性有要求还要考虑队列满时丢帧策略而不是无脑阻塞。4.3 解码与绘制分离如果你既要录制又要在应用界面上做预览建议把预览和录制彻底分开。预览走一个低分辨率通道录制走全分辨率通道。很多人卡顿就是因为预览窗和录制共用同一个Mat对象。我现在习惯的做法是拉流拿到帧之后立刻克隆一份低分辨率缩略图给UI线程显示原帧继续走录制管线。预览用CvInvoke.Resize缩小到宽度640这个分辨率下就算做区域绘制也不会太消耗CPU。4.4 CamShift、画框、OSD叠加的性能代价项目里如果有要在视频上实时叠加矩形框、文字、时间戳的操作注意这些绘制函数也能成为性能瓶颈。尤其是CvInvoke.PutText在4K分辨率下每帧绘制几个中文字符CPU占用很恐怖。我的经验是不要在每一帧都创建新的FontFace、Bgr对象这些对象尽量在循环外创建复用。如果是方框检测那种绘制尽量用整数坐标避免不必要的浮点转换。如果只是叠加时间戳可以直接用DateTime.Now生成字符串后绘制但字体不要选太大的。如果你必须叠加大量文字内容还有一个思路先绘制在独立的小图上再把小图通过CvInvoke.AddWeighted融合到主图。这个对性能提升很明显但代码复杂度会上去。5. 断线重连与异常恢复机制5.1 摄像头断流是常态不是异常项目上线跑了几个月我最大的感触就是RTSP摄像头断流是常态不是偶然。交换机重启、摄像头死机、网络拥堵都会导致capture.Read返回失败或者长时间阻塞。所以录制程序必须把断线重连做成标配功能否则半夜里摄像头掉线第二天早上才发现已经有一段空白。我的做法是如果连续读取失败超过3次就销毁当前VideoCapture等待2到3秒重新创建。重连时要注意清空旧的缓冲队列否则读取到的可能还是断线前的旧帧。5.2 自动补录和文件完整性处理断线期间的录像肯定是补不回来的除非你有本地缓存机制。比如摄像头支持SD卡存储或者你的程序在断线期间把画面保存成逐帧JPEG。一般场景下断线重连能保证后续录像不中断就已经达到要求了。重连之后要注意文件名的时间戳。如果你重连的时间点和上一段差了好几分钟文件名就会体现出一个跳变。我建议在文件名里加上“连接序号”或者“重连标记”string fileName $record_{segmentStartTime:yyyyMMdd_HHmmss_fff}_conn{_connectionIndex}.avi;这样录像文件如果出现时间轴不连续回溯时一眼就能看出这是断线重连导致的。5.3 优雅退出和资源释放程序要支持手动停止而且停止时要把当前的视频文件正确关闭。用CtrlC直接杀进程会导致当前文件损坏虽然分段文件只丢最后几秒但严格来说是不安全的。可以监听Console.CancelKeyPress或者Windows服务里的OnStop事件在停止时先停止拉流线程、等待缓冲队列排空、最后释放VideoWriter。这一步对文件完整性很重要。5.4 多线程写日志与异常收集长时间运行的程序日志系统一定要有。我在每个关键节点都写日志拉流开始、分段切换、断线重连、异常退出。日志框架用NLog或者Serilog都行重点是把异常堆栈记录下来。曾经遇到过一个诡异问题程序稳定跑三天后内存涨到几个G。后来靠日志发现帧队列满了之后某些分支把Mat对象落在地上没有Dispose导致非托管内存泄漏。内存问题必须靠日志辅助排查只靠肉眼看代码很难发现。6. 常见问题与排查技巧实录6.1 无法加载Emgu.CV类型初始化失败这个问题在论坛里出现频率很高。报错一般是Emgu.CV.CvInvoke”的类型初始值设定项引发异常或者无法加载一个或多个请求的类型。有关更多信息请检索 LoaderExceptions 属性。基本原因就三类没有安装VC运行库OpenCV的DLL加载不起来。Emgu.CV.runtime.windows包没有随主包一起安装缺少原生DLL。程序集绑定失败比如x64/x86不匹配。排查方法打开AppDomain.CurrentDomain.AssemblyResolve事件把所有Load失败的DLL路径打出来看缺的是哪个针对性解决。6.2 海康摄像头RTSP URL进不去海康摄像头的RTSP URL格式容易记错。主码流是rtsp://username:password192.168.1.64:554/Streaming/Channels/101子码流是rtsp://username:password192.168.1.64:554/Streaming/Channels/102如果密码里有特殊字符比如、:、/一定要做URL编码。我踩过一次坑密码里有个结果整个地址被解析错乱拉流一直超时。后来用Uri.EscapeDataString处理密码部分就好。6.3 录制文件时长比实际时间短你录了10分钟但视频文件播放出来只有8分钟帧率还对不上。这种情况一般是你创建的VideoWriter帧率参数和实际写入的帧数不匹配。比如源流是15fps但你VideoWriter写的25fps那么文件播放很快。解决办法是从摄像头直接读Fps属性不要手动指定。还有如果摄像头是动态码率实际帧率和标称值可能有出入更稳妥的做法是计算平均帧率double fps capture.Get(Emgu.CV.CvEnum.CapProp.Fps); if (fps 0 || fps 60) fps 25; // 兜底值6.4 断线后重连一直失败如果重连三次还是失败不要立即疯狂重试。每次重试之间间隔加长比如1秒、2秒、5秒、10秒、30秒最大间隔封顶。否则摄像头还在恢复阶段你这一顿猛连反而可能把它拖得更慢。我的重连策略是每次重试间隔翻倍最多30秒一次。6.5 内存持续增长最终OOM这个前面提过九成是Mat对象没释放。尤其注意frame.Clone()出来的Mat用完之后要Dispose()。Mat是托管对象但内部持有的是非托管内存GC压力很大资源释放不能只靠GC。我代码里习惯用using或者try-finally确保每个Clone出来的Mat都能被释放。如果图像处理里用到了中间Mat也要注意局部作用域用完就释放。6.6 录像文件打不开或者花屏文件打不开优先检查VideoWriter构造函数有没有抛异常。OpenCV创建编码器失败时不会立刻报错而是等到写第一帧时才报。更隐蔽的是有些编码器比如XVID在特定分辨率下会生成损坏文件。如果录像文件花屏一般是码率控制问题。可以试着降低分辨率或者调整编码器参数。顺带说明用MJPG编码虽然体积大但基本不会花屏适合做基础保障。6.7 帧卡顿问题排查清单针对文章开始提到的“RTSP流画框推送为新RTSP流总是卡顿”排查顺序我一般是这样先确认纯拉流不绘制不写视频CPU占用多少。再加绘制看CPU增量。再加推流或者录像看哪个环节卡。大部分情况是绘制环节用了大量GDI操作或者推流时编码耗时太长。解决办法是降低绘制频率比如每3帧画一次框或者把推流分辨率降下来。6.8 跨平台部署的注意事项如果你的程序要部署到Linux服务器上Emgu.CV也能跑但要注意几点需要安装OpenCV的native依赖比如libopencv-dev。字体渲染可能有问题中文OSD需要额外处理字体文件。摄像头RTSP地址里的IP和端口要确认网络策略放行。我一般是在Windows上开发调试最终部署到Linux服务器。C#跨平台这块用.NET 6以上很好用但如果依赖老库的话还是得仔细测试。7. 项目经验总结与扩展思路7.1 我对这个方案的整体评价用Emgu.CV做RTSP拉流和分段录制这个方案胜在灵活。你可以在录制的同时加任何图像处理逻辑这是直接用FFmpeg命令行很难做到的。代价是性能不如FFmpegCPU占用会高一些但一般工控机都能扛得住。如果你只是想要一个纯录制功能推荐直接调用FFmpeg进程。但如果你想做一个“聪明”的录像系统比如带移动侦测录制、人脸识别联动、异常事件标记那Emgu.CV这条路就是对的。7.2 后续可扩展的方向这套基础框架搭好之后往上加东西很容易加一个帧分析器接口让每个摄像头可以挂不同算法。加一个录像索引数据库记录每个片段的开始时间、结束时间、事件标记。加一个磁盘空间管理自动清理超期片段。加一个告警联动检测到画面变化就发通知。这些功能都在现有框架上叠加就行核心拉流、录制、分段的代码不用动。7.3 源码获取整个项目代码我整理过了核心类就两三个代码量不大但都是生产环境验证过的。最后说一个做这类项目最实际的体会不要迷信某个框架或者某个库能解决所有问题RTSP拉流本身并不难难的是在高并发、弱网、长时间运行这些真实场景下保持稳定。你写代码的时候一定要把“摄像头会掉线、网络会抖动、磁盘会写满”这些情况当默认条件而不是异常分支。抱着这种心态去设计你录出来的每一段视频才真正可靠。如果你正在做类似项目建议先拿一个测试摄像头跑通全流程再去接批量设备。把分段、重连、异常处理这几个基础打好后面不管接多少路摄像头都有底气。本文还有配套的精品资源点击获取
返回列表