C#上位机RTSP拉流分段录像:Emgu.CV实现MP4按时间切割与断线重连
发布时间:2026/10/2 8:46:12来源:尧图网络
简介这份C#源码工程面向需要在Windows平台实现RTSP视频流读取与分段录制的开发者基于VS2019与.NET Framework 4.7.2集成Emgu CV 4.8.0适用于安防监控、视频巡检、实时录像存档等场景。压缩包共60个文件约93.55MB含20个DLL运行库、10个C#源码文件、5个XML配置文件、3个PDB调试符号以及可执行程序、项目工程、界面资源和设置文件等工程结构完整便于直接编译与二次开发。已有911人浏览学习。通过VideoManager、Form1等核心模块可掌握Emgu CV中VideoCapture读取RTSP流、按时间分段保存视频文件的实现思路并结合配套博客和视频演示理解文件分段时长控制、格式封装及界面集成的常见问题与排除方法适合希望快速落地视频流录制功能的C#开发者。1. 用Emgu.CV在C#里拉RTSP流做分段录像这套代码解决什么问题做C#上位机和视觉项目时最不缺的就是这种需求摄像头对着产线程序一边做识别一边把RTSP流录下来留档。我接过好几个类似项目甲方要求出奇一致——用C#、用Emgu.CV录像文件按时间分段保存单段不能超过固定时长断电断网后要能自己续上。这篇就按这个需求讲落地方式怎么读RTSP拉流协议、怎么取帧、怎么按时间切段写MP4再讲几个跑几天才暴雷的坑。适合两类人一是上位机里要附带录像功能但不想引FFmpeg那套复杂工程的人二是已经在用Emgu.CV做识别顺手想把原始视频留存下来的开发者。2. RTSP拉流与取帧为什么选Emgu.CV而不是FFmpeg或DirectShow2.1 三条路的取舍Emgu.CV站在FFmpeg后端之上RTSP拉流协议本身不复杂麻烦的是解封装、丢包重传和音视频同步。OpenCV的VideoCapture在Windows上默认走FFmpeg后端Emgu.CV只是把这层能力封装成C#能直接调用的类。所以你想用C#读RTSP本质上三条路直接拉起FFmpeg进程做管道通信用OpenCVSharp或者用Emgu.CV。直接拉FFmpeg进程能力最强想做RTSP转发、转FLV、加字幕都得靠它但代价是要处理标准输入输出、进程异常退出和参数逃逸上位机里维护成本不低。OpenCVSharp和Emgu.CV的底层其实是同一套native代码区别在C#层的API设计OpenCVSharp更贴近C原生风格网络上的示例也多是OpenCVSharp配RTSP流为TCP这类局部配置Emgu.CV的Mat、VideoCapture、VideoWriter用起来更像在写C#Dispose和using顺手得多这对要长期跑录像的进程很关键。DirectShow那条路只适合本地USB摄像头UVC回调里区分多个摄像头可以一旦换成网络摄像头就使不上劲。选型还有一个现实理由很多识别算法本身就跑在Emgu.CV上。录像功能顺手放在同一套库里不用单独引一套视频处理栈出问题也只查一个依赖。2.2 最小取帧代码先证明链路是通的using Emgu.CV; using Emgu.CV.CvEnum; string rtspUrl rtsp://user:pass192.168.1.64:554/Streaming/Channels/101; using (var capture new VideoCapture(rtspUrl)) using (var frame new Mat()) { for (int i 0; i 30; i) // 先读30帧验证链路通不通 { if (!capture.Read(frame)) // Read是阻塞的失败返回false break; if (frame.IsEmpty || frame.Width 0) break; Console.WriteLine($第{i}帧 {frame.Width}x{frame.Height}); } }这段代码负责回答一个核心问题这台机器到底能不能从摄像头取到帧。new VideoCapture(rtspUrl)不会立刻告诉你网络通不通它可能阻塞几十秒才失败所以读帧才是真正的探活。RTSP地址的写法要按设备厂商来。海康威视网络摄像头常见的格式是rtsp://账号:密码IP:554/Streaming/Channels/101其中101是主码流102是子码流大华常见的是rtsp://账号:密码IP:554/cam/realmonitor?channel1subtype0。具体路径以设备说明书为准但规律是一样的账号密码里有特殊字符要先做URL编码不然URL解析会提前截断。2.3 打开摄像头后第一件事校准fps和分辨率取到帧之后别急着写录像先把fps和分辨率读出来。VideoCapture的Get方法在不少设备上会返回0或一个不准确的值尤其是网络摄像头主码流实际25帧上报却是15帧。fps一旦错了后续VideoWriter写出来的文件看起来像是快进或慢放而且你按时间切段也会跟着错位。private int EstimateFps(VideoCapture cap, int fallback) { int count 0; var begin DateTime.Now; using (var tmp new Mat()) { while ((DateTime.Now - begin).TotalSeconds 2) { if (cap.Read(tmp) !tmp.IsEmpty) count; } } return count 0 ? count / 2 : fallback; // 2秒内帧数除以2 }这个估算方法会真实消费摄像头缓冲里的帧所以调用完这2秒对应的画面不会出现在录像里。我一般把这段校准放在取流成功之后、正式开始录像之前损失两秒画面换来正确的文件时长值得。分辨率同理读CapProp.FrameWidth和CapProp.FrameHeight如果读到0就用1920x1080兜底并在日志里标出来。3. 分段保存的核心实现按时间切MP4的完整代码3.1 分段策略按时间切是首选按大小切是坑录像分段有三种常见策略实际体验差别很大。按文件大小切是很多初版方案的首选但码率是波动的画面静止时一兆都可能录一分钟画面运动剧烈时十秒就几兆结果就是每段时长忽长忽短回放检索时你根本不知道某段对应哪个时间点。按关键帧切需要拿到GOP信息去对齐I帧Emgu.CV的VideoCapture不直接暴露关键帧标记要么自己解析要么每次切段都在I帧边界等待实现成本高。按时间切最直观每段固定60秒或300秒文件名带起始时间回放和归档都清晰。缺点同样存在切割点可能落在非关键帧上播放器打开文件时要从第一个可解码帧开始所以段头偶尔会黑一秒或卡一下这个用Mp4v封装后有一定缓解。3.2 FourCC与容器Mp4v、MJPG还是H264VideoWriter用什么编码直接决定文件能播、文件多大、CPU占用多高。这几个选项我都在Windows上位机上跑过差异很明确。FourCC封装CPU占用文件体积兼容性适用场景MP4VMP4中中等几乎全兼容默认首选MJPGAVI低极大全兼容老旧工控机X264MP4高小依赖系统编码器对存储敏感代码里创建Writer用的是VideoWriter.FourCC(M, P, 4, V)注意这个Mp4v和H264里的AVC不是一回事前者是MPEG-4 Part 2兼容性最好后者才是H.264。Emgu.CV的VideoWriter没有稳定可靠的码率参数可调OpenCV官方对码率控制一直很保守所以想控文件大小只能换编码或降帧率。想用H264得先确认目标机器上有OpenH264或系统编码器否则运行时直接抛异常这也是我默认用Mp4v的原因。3.3 核心代码录像线程里的分段写入private readonly object _writeLock new object(); private VideoWriter _writer; private DateTime _segmentStart; private volatile bool _needStop; void RecordLoop(string rtspUrl, string saveDir, int segmentSeconds) { using (var capture new VideoCapture(rtspUrl)) { // 切到TCP传输减少花屏版本差异见4.4节 capture.Set(Emgu.CV.CvEnum.CapProp.RtspTransport, 1); int fps (int)capture.Get(Emgu.CV.CvEnum.CapProp.Fps); if (fps 0) fps 25; int width (int)capture.Get(Emgu.CV.CvEnum.CapProp.FrameWidth); int height (int)capture.Get(Emgu.CV.CvEnum.CapProp.FrameHeight); if (width 0 || height 0) { width 1920; height 1080; } fps EstimateFps(capture, fps); // 2.3节的方法优先用实测值 _segmentStart DateTime.Now; _writer CreateWriter(saveDir, _segmentStart, fps, width, height); using (var frame new Mat()) { while (!_needStop) { bool ok capture.Read(frame); // 阻塞读帧 if (!ok || frame.IsEmpty) { Thread.Sleep(50); // 空帧时别让CPU空转 continue; } lock (_writeLock) { DateTime now DateTime.Now; if ((now - _segmentStart).TotalSeconds segmentSeconds) { _writer.Dispose(); // 先关旧文件再建新文件 _segmentStart now; _writer CreateWriter(saveDir, now, fps, width, height); } _writer.Write(frame); } } } } _writer?.Dispose(); } private VideoWriter CreateWriter(string dir, DateTime t, int fps, int width, int height) { string file Path.Combine(dir, $seg_{t:yyyyMMdd_HHmmss_fff}.mp4); int fourcc VideoWriter.FourCC(M, P, 4, V); return new VideoWriter(file, fourcc, fps, new Size(width, height), true); }切段逻辑放在写入前判断意思是当当前帧的时间戳已经超出段边界时先释放旧Writer、拿当前时间做新段起点再把这一帧写进新文件。这么做能保证分段瞬间的连续帧不会丢新文件也能从非空内容开始。锁的作用是防止切段和Write并发打架。实际项目里录像线程是唯一写这个Writer的线程但后续你很可能把“显示画面到UI”或“触发抓拍”也接入没有锁切段瞬间另一条线程还在调_writer.Write轻则文件头损坏重则AccessViolation。文件名里带了毫秒_fff是因为极端情况下同一秒可能切两次段比如手动重连后重置了段起点不带毫秒就会把前一个文件直接覆盖。3.4 开始、停止与收尾不要让最后一个文件是坏的录像要用独立线程跑不能在UI线程里阻塞。启动很简单new Thread(() RecordLoop(url, dir, 300)) { IsBackground true }.Start()。停止时别直接调用Dispose外部只负责把_needStop置true让循环自然退出最后在录像线程内部释放Writer。public void Stop() { _needStop true; // 如果录像线程因Read阻塞卡住可以等它自己超时返回 // 必要时用一个“强制停止”标志在1秒后再关capture。 }停止那一刻正在写的文件很多人直接不管结果最后一段永远只有几KB。正确做法是循环退出后先Dispose Writer再结束线程这一步会把文件头尾补写完。还有一个上位机常见的需求录完一段想知道文件路径。别让UI线程去轮询目录定义一个event Actionstring SegmentFinished;在切段后触发C#的委托和事件在这里用正合适既解耦又不引入额外库。4. 录像落盘高频踩坑花屏、崩溃与空文件的排查记录4.1 第一段能播后面的文件只有几KB现象录出来的目录里第一个文件正常从第二个开始文件大小只有几KB播放器打不开。原因切段时直接在旧Writer上new新Writer或者切段逻辑和写入逻辑并行执行。VideoWriter底层的FFmpeg句柄在上一个文件还没关闭时就被抢占文件头信息没写完整整个MP4就废了。解决切段逻辑收敛到录像线程里串行执行严格按“先_writer.Dispose()再创建新Writer”的顺序来。再加一个锁杜绝其他线程在切段瞬间碰Writer。这条是分段录像里最高频的坑没有之一。4.2 内存和GDI句柄一起涨跑一天后卡死现象程序刚启动内存只有200MB跑了8小时涨到1.5GBUI还报“句柄数过多”。原因每帧都new了Mat或Image却没有Dispose。C#里对象有GC兜底但OpenCV的native内存不在托管堆上GC来不及回收就爆了。更隐蔽的是预览窗口很多人直接拿Image.ToBitmap()丢给PictureBox然后不处理那个BitmapGDI句柄直线上升。解决录像用的Mat用using包住或者每帧结束后显式frame.Dispose()。给UI送预览图时先Bitmap.Clone()再丢弃源Bitmap或者干脆在UI侧用完之后Dispose。这条排查起来最耗时因为内存曲线是慢慢爬上去的跑一两天才看到结果。4.3 C#调用底层库偶发AccessViolationc0000005现象程序运行几小时后偶发AccessViolationException错误码c0000005堆栈指向Emgu.CV内部日志完全看不出触发点。原因跨线程释放了VideoCapture或VideoWriter。C#层允许你在线程A创建、线程BDispose但C层面的RAF句柄不知道这件事释放时机一错就踩到native内存。另一个常见来源是视频路径里有中文FFmpeg打开失败后对象进入非法状态却没有被及时释放。解决创建和释放都放在录像线程内外部只通过标志控制生命周期路径一律用英文目录摄像头名称用编号代替如果确实需要跨线程释放至少先GC.KeepAlive(writer)再Dispose并做好异常捕获。这条就是C#调用C库最典型的边界问题遇到一次就长记性。4.4 网络抖动后花屏、马赛克甚至录不进去现象局域网里摄像头画面静止时一切正常一旦网络波动录出来的文件中间全是马赛克甚至整段时间写着写着就断了。原因RTSP默认走UDP传输UDP丢包不重传画面自然就花。Emgu.CV的VideoCapture在Windows上默认传输方式可能是UDP弱网环境直接翻车。解决把拉流传输协议切成TCP。代码里写的是capture.Set(CapProp.RtspTransport, 1)数值1代表TCP。注意不同版本的Emgu.CV枚举名可能不叫RtspTransport编译不过就查OpenCV头文件里CAP_PROP_RTSP_TRANSPORT的数值常见是191直接capture.Set(191, 1)也能生效。这是血泪经验TCP下帧率高不了多少但花屏基本绝迹。4.5 切段文件时间轴有间隙现象相邻两段文件首尾时间不连续中间丢了3到5秒画面人工回放时明显跳一下。原因切段时要释放旧Writer、创建新Writer、补文件头这个耗时有几十毫秒到几百毫秒如果切段逻辑在锁内、阻塞了取帧这一小段时间的画面就被漏掉了。网络摄像头是流式发送本地不缓存已播的画面漏了就补不回来。解决把capture.Read(frame)放到锁外面锁内只做“判断切段Write”让录像线程在切段期间尽量少停摆。要求更严的场景就改成生产者消费者模型取帧线程把Mat塞进队列写盘线程从队列取切段只发生在写盘线程内部取帧完全不受影响。这个改造代码量不大效果立竿见影。5. 断线重连与7x24小时无人值守5.1 断线是常态用帧间隔而不是Read返回值判活摄像头重启、交换机拥塞、网线松动都会让RTSP流断掉。麻烦的是capture.Read在TCP连接断开后不一定立即返回false它可能阻塞在底层重传上一直等到超时。所以不能用一次Read的返回值判断是否断流而是在录像循环里维护一个lastFrameAt时间戳每次取到有效帧就更新。判活阈值我一般都设10秒。超过10秒没取到新帧就认为这条流已经死了进入重连流程。这个值不能设太小摄像机偶尔会停顿两三秒设3秒容易误判重连造成频繁断开会话设太长断流后恢复得慢实时场景就废了。5.2 退避重连与会话恢复private VideoCapture CreateCaptureWithRetry(string rtspUrl, int maxAttempts 5) { for (int attempt 1; attempt maxAttempts; attempt) { try { var cap new VideoCapture(rtspUrl); if (cap.FrameWidth 0 cap.FrameHeight 0) return cap; cap.Dispose(); } catch (Exception ex) { Console.WriteLine($第{attempt}次连接失败: {ex.Message}); } Thread.Sleep(Math.Min(1000 * attempt, 30000)); // 1s,2s,3s逐级退避封顶30s } throw new TimeoutException(重连次数用尽); }重连逻辑接进录像循环时先判断心跳超时再执行重连然后重置lastFrameAt和_segmentStart。重连后段起点必须重置否则摄像头断流期间累计的计时会让新文件只录几百毫秒就到切段点。摄像头会话有冷却时间刚断开的地址立刻重连大概率失败退避公式用Math.Min(1000 * attempt, 30000)也就是第一次等1秒、第二次等2秒、依次翻倍封顶30秒。我一般把总重试次数设成5次失败后持续循环而不是直接放弃因为夜班时段没人守在机器旁边。重连成功后还要重新校准一次fps和宽高。摄像头重启后可能切换了码流配置不校准就用旧参数写Writer轻则时长失真重则创建失败。校准代码复用2.3节的EstimateFps即可。5.3 多路摄像头并发录制每路一条独立线程一个摄像头一套线程模型一个线程持有一个VideoCapture和一个VideoWriter各路的Mat、Writer完全独立绝不共享。用Task数组启动多路很直接ListTask tasks new ListTask(); foreach (var cam in cameraList) { tasks.Add(Task.Run(() RecordLoop(cam.RtspUrl, cam.SaveDir, 300))); } Task.WaitAll(tasks.ToArray());多路并发时最大的坑是停止。Task.WaitAll会阻塞当前线程如果其中一路卡在capture.Read的重试上整个停止流程就被拖死。我在停止时先对所有路置_needStop true再等5秒超时就把还没退出的线程标记为“已放弃”强制结束进程丢失最后一段也比整机挂住强。这类需求经常出现在C#上位机里和PLC通信、数据展示、报警联动放在同一个进程。记住一点录像线程是纯后台任务优先级不要拉高也不要让它和UI抢主线程否则一路摄像头断流重连就能让整个上位机卡住。6. 验证一条录像链路的正确做法播放器、时间轴与GPU占用三重检查6.1 用ffprobe核对每段时长与帧数录完一批文件先别急着用播放器人肉看用ffprobe把所有段的时长拉出来一眼就能发现fps是否失真、有没有写残的段。for f in seg_*.mp4; do d$(ffprobe -v error -show_entries formatduration -of csvp0 $f) s$(stat -c %s $f) echo $f duration${d}s size${s}B done时长接近设定段长说明写入帧率正确。如果所有段都比设定值短了5%以上基本是EstimateFps没执行到位写入fps偏大。文件大小悬殊是正常的画面运动量不同码率就不同但出现几百字节的文件就要倒查切段逻辑和锁。6.2 时间戳拼接回放确认段间无缝隙用文件名里的起始时间加时长做校验下一段的起始时间应当约等于上一段起始时间加上设定段长。偏差在1秒内属于正常因为切段操作本身有几帧的误差偏差超过3秒说明切段阻塞了取帧回到4.5节的队列方案去修。也可以用VLC这类支持RTSP视频流播放的工具直接打开摄像头地址对比原始画面和录像画面确认“录进去的视频就是现场看到的那一路”这一步能筛掉码流通道选错的问题。6.3 老化测试与两点个人习惯交付前连续跑一周老化测试每小时拉一次文件清单检查有没有中断、空文件和时长漂移。我最早做的录像程序把切段逻辑放在UI线程一切段界面就卡跑了半天内存爆掉后来才改成“取帧线程入队、写盘线程出队”的模型。现在接到这类需求我第一件事是问清楚断流后能不能容忍丢几秒能容忍就单线程简单写不能容忍就直接上队列。重连后必须重置段起点这个习惯帮我少删了很多无效文件。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网