
简介面向WinForm开发者这份C#资源实现了多路IP摄像头画面预览与截图并集成了IP通道管理可直接用于海康威视等设备的二次集成。通过输入IP、端口、账号密码即可接入预览支持BMP/JPEG抓图、缓冲区保存以及客户端录像等基础功能适合安防监控、桌面端视频采集场景。资源共119个文件包含10个C#源码工程、3个配置与工程文件、运行时依赖DLL、演示截图等整体约14.07MB结构紧凑便于快速移植。已有2043人学习使用覆盖从摄像头接入到截图保存的核心交互并展示出界面布局与操作流程对需要快速实现多路预览或搭建小型视频监控原型的开发者具有直接参考价值。1. 多路IP摄像头画面预览加截图为什么直接调播放器内核会翻车某工厂车间的中控室要同时看 9 路网络摄像头的实时画面还得每 5 秒自动截一张图存档。第一版图省事直接用 WinForms 加第三方播放控件结果开到第 5 路画面就开始 CPU 爆满、缓冲越拉越长截图还经常黑屏。换用 C# 自研多路预览与截图方案后9 路 1080P 同时跑预览延迟控制在半秒内截图用的是解码后的原始帧而不是屏幕画质和时序都可控。这篇笔记就讲清楚这套方案怎么做FFmpeg 负责拉流解码D3D11 共享纹理负责 GPU 渲染每个摄像头一路采集线程预览线程只拿最新帧截图和预览互不阻塞。2. 先把方案立住FFmpeg 拉流 D3D11 共享纹理不走 GDI 的 3 个理由2.1 从 RTSP 到像素帧FFmpeg 在 C# 里的接口边界与线程职责IP 摄像头绝大多数走 RTSP 协议RTSP 里封装的是 H.264 或 H.265 编码流。C# 这边没有官方原生解码库最可靠的做法是把 FFmpeg 的 C 接口用 P/Invoke 或者现成的 AutoGen 绑定拉进来。FFmpeg 在这一整套方案里只干一件事把 RTSP 流解成原始画面帧。输出的是 AVFrame里面通常是 YUV420P 格式后续转成 BGRA 才能交给 GPU 纹理。我一般会为每个摄像头建立一个 CameraSession 对象里面包含一路解码线程和一个 FrameBuffer。解码线程负责 avformat_open_input、avcodec_open2、循环 av_read_frameFrameBuffer 只保存最近一帧的 BGRA 像素不是队列。为什么用最新帧覆盖而不是排队多路预览场景里UI 渲染速度赶不上解码速度时队列只会越积越长延迟越来越大。丢掉旧帧、只留最新帧是安防预览里最常见的策略。2.2 预览渲染选型GDI、D2D、D3D11 纹理共享的取舍很多人第一反应是拿到 BGRA 数据后用 System.Drawing 画到窗体上。单路 720P 还能忍多路 1080P 就出问题GDI 的 BitBlt / DrawImage 是 CPU 软拷贝一路还能跑四路同时全屏缩放就是灾难。而且 GDI 截图只能截窗口表面的内容窗口被遮挡、最小化、缩放时截出来的图跟实际画面对不上。我实际测过的渲染路径有三条GDI 直接画、WPF 的 WriteableBitmap、D3D11 纹理。对多路摄像头来说D3D11 是唯一长时间不掉线的选择。渲染路径多路 1080P 实测表现截图来源跨线程安全性GDI 直接画第 4 路开始 CPU 满载拖窗口掉帧只能截屏幕容易黑屏需要外部加锁容易闪烁WPF WriteableBitmap比 GDI 好但大尺寸缩放开销高可以截像素缓冲依赖 UI 线程 Dispatcher解码线程写入要小心D3D11 共享纹理缩放交给 GPUCPU 只做一次拷贝可以直接存 BGRA 帧不经过屏幕配合 IDXGIKeyedMutex 实现多线程访问结论很直接渲染用 D3D11 纹理截图不依赖渲染结果而是直接从解码线程的 FrameBuffer 里取原始 BGRA。预览和截图彻底解耦这是这套方案最核心的设计。2.3 多路架构解码线程、预览线程、截图线程怎么拆分常见做法是三线程模型。解码线程每路一个互不干扰预览线程由 UI 定时器驱动把所有摄像头的最新帧合成到窗口布局里截图线程按需或定时触发从 FrameBuffer 拿数据写文件。三个线程之间通过帧号FrameIndex做版本判断。// 架构骨架三个角色各司其职 public sealed class CameraSession : IDisposable { private Thread _decodeThread; public FrameBuffer LatestFrame { get; } new FrameBuffer(); public string Url { get; } public int Fps { get; private set; } public bool IsConnected { get; private set; } public void Start() { _decodeThread new Thread(DecodeLoop) { IsBackground true, Priority ThreadPriority.AboveNormal // 采集优先级高于渲染 }; _decodeThread.Start(); } private void DecodeLoop() { /* FFmpeg 循环见第 3 章 */ } }这段代码解释了职责边界解码线程优先级设 AboveNormal是因为解码跟不上时所有下游都会卡渲染线程不要直接解码。IsConnected 状态由解码线程维护UI 线程只读避免跨线程频繁锁。FrameBuffer 内部用 lock 包住写入和读取保证 BGRA 数组不撕裂。3. 把 RTSP 帧送进共享纹理一份可执行的核心代码3.1 FFmpeg 解码封装与帧获取以下代码按 FFmpeg 较新版本 API 写绑定层用 AutoGen 或手写 P/Invoke 均可函数签名一致。核心是 av_read_frame 循环遇到视频流 packet 就喂给解码器avcodec_receive_frame 拿到 AVFrame 后转 BGRA。// 解码线程循环项目里用 unsafe 块包住指针调用 private unsafe void DecodeLoop() { AVFormatContext* fmtCtx null; var dict new AVDictionary*(); av_dict_set(dict, rtsp_transport, tcp, 0); // TCP 拉流避免 UDP 花屏 av_dict_set(dict, stimeout, 3000000, 0); // 3 秒无响应判定超时 if (avformat_open_input(fmtCtx, _url, null, dict) ! 0) { OnError(Open input failed); return; } avformat_find_stream_info(fmtCtx, null); AVCodec* codec null; int videoIndex av_find_best_stream(fmtCtx, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, codec, 0); var codecCtx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codecCtx, fmtCtx-streams[videoIndex]-codecpar); avcodec_open2(codecCtx, codec, null); var frame av_frame_alloc(); var packet av_packet_alloc(); var swsCtx sws_getContext(codecCtx-width, codecCtx-height, codecCtx-pix_fmt, codecCtx-width, codecCtx-height, AVPixelFormat.AV_PIX_FMT_BGRA, SWS_BILINEAR, null, null, null); while (!_cancelled) { int ret av_read_frame(fmtCtx, packet); if (ret 0) { OnDisconnected(); // 触发外部重连逻辑 break; } if (packet-stream_index videoIndex) { avcodec_send_packet(codecCtx, packet); while (avcodec_receive_frame(codecCtx, frame) 0) { // 转成 BGRA 后写入 FrameBuffer byte[] bgra new byte[frame-width * frame-height * 4]; byte* dst; fixed (byte* p bgra) { dst p; // 一次 sws_scale 完成格式转换目标 stride 为 width*4 sws_scale(swsCtx, frame-data, frame-linesize, 0, frame-height, dst, frame-width * 4); } _latestFrame.Write(bgra, frame-width, frame-height, _frameIndex); } } av_packet_unref(packet); } }这段代码的关键参数rtsp_transport 强制 TCPUDP 在弱网下会马赛克stimeout 设为 3 秒摄像头掉线时 av_read_frame 最多阻塞 3 秒而不是永远挂起。FrameBuffer 的 Write 方法内部用 lock 锁住数组引用并替换而不是原地拷贝这样读线程拿到的是完整的帧引用。sws_scale 的 SWS_BILINEAR 是速度和画质的平衡点对多路预览够用单路需要更高画质可以换 SWS_LANCZOS但 CPU 开销会明显上涨。3.2 把 BGRA 数据送进 D3D11 纹理共享纹理与 KeyedMutex多路画面不能每路一个独立 D3D 设备到处乱跨。我习惯让所有 CameraSession 共用同一个 D3D11 设备每个摄像头持有一张 ID3D11Texture2D 共享纹理。解码线程写入纹理时用 IDXGIKeyedMutex 做所有权切换防止渲染线程正在读的时候被覆盖。// 为每个 CameraSession 创建共享纹理 public void CreateTexture(int width, int height) { var desc new Texture2DDescription { Width width, Height height, MipLevels 1, ArraySize 1, Format Format.B8G8R8A8_UNorm, // BGRA 字节序与解码输出一致 SampleDescription new SampleDescription(1, 0), Usage ResourceUsage.Default, BindFlags BindFlags.ShaderResource, MiscFlags ResourceMiscFlags.Shared | ResourceMiscFlags.KeyedMutex }; _texture _device.CreateTexture2D(desc); _keyedMutex _texture.QueryInterfaceIDXGIKeyedMutex(); } // 解码线程拿到新帧后更新纹理 public void UpdateTexture(byte[] bgra, int width, int height) { _keyedMutex.AcquireSync(0, 1000); // 请求写入权1 秒超时 _context.UpdateSubresource(bgra, _texture, 0, new DataBox(bgra, width * 4, height)); _keyedMutex.ReleaseSync(1); // 释放写入权通知渲染线程可以读了 }这里两个同步 key 值 0 和 1 是约定AcquireSync(0) 表示写线程拿锁渲染线程读之前也调用 AcquireSync(0)谁先拿到谁用。ReleaseSync(1) 只是解锁信号key 只要保持一致即可。格式用 B8G8R8A8_UNorm 是因为 FFmpeg 转出的 BGRA 字节序正好匹配省一次像素重排。UpdateSubresource 是纯 GPU 上传解码线程调它开销可控但要注意这个调用必须在成功 AcquireSync 后才做否则纹理数据可能只更新一半。3.3 渲染循环与截图保存从后台帧拿数据而不是从屏幕预览 UI 用一个 WinForms 定时器驱动渲染典型间隔 33ms约 30 FPS。每个 CameraSession 渲染时取出共享纹理作为 ShaderResourceView按布局矩形绘制到主窗口。截图保存则走独立的 FrameBuffer完全不碰纹理和屏幕。// 截图服务从 FrameBuffer 直接保存原始帧 public static void SaveFrame(CameraSession session, string filePath) { var snapshot session.LatestFrame.GetSnapshot(); // lock 内取出最新帧引用 using var bmp new Bitmap(snapshot.Width, snapshot.Height, PixelFormat.Format32bppArgb); var data bmp.LockBits(new Rectangle(0, 0, snapshot.Width, snapshot.Height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); Marshal.Copy(snapshot.BgraData, 0, data.Scan0, snapshot.BgraData.Length); bmp.UnlockBits(data); bmp.Save(filePath, ImageFormat.Jpeg); }Bitmap 的 Format32bppArgb 在小端机器上内存字节序是 BGRA和 FFmpeg 转出来的 BGRA 完全一致不需要像素级转换。截图走原始帧的好处很多窗口最小化不受影响、画面缩放画质不损失、截出来的时间点完全可控。GetSnapshot 里的 lock 只持有一个引用交换的时间不会阻塞解码线程超过几十纳秒。4. 多窗口布局与批量截图从单路 Demo 变成能用的多路程序4.1 1/4/9/16 宫格布局如何不阻塞解码线程多路预览的布局本质是一道除法题把客户区分成 m 行 n 列每个格子按纵横比填充画面剩余区域用黑色补边。计算放在渲染循环入口避免每帧重复算。// 宫格布局计算返回每个摄像头在窗口内的目标矩形 public static Rectangle CalculateCell(int index, int rows, int cols, int clientWidth, int clientHeight) { int cellW clientWidth / cols; int cellH clientHeight / rows; int row index / cols; int col index % cols; // 16:9 画面在 4:3 格子里要留黑边等比缩放 int targetW cellW; int targetH targetW * 9 / 16; if (targetH cellH) { targetH cellH; targetW targetH * 16 / 9; } int x col * cellW (cellW - targetW) / 2; int y row * cellH (cellH - targetH) / 2; return new Rectangle(x, y, targetW, targetH); }布局切换时最忌讳的做法是销毁重建所有纹理和渲染资源。我一般只改每个格子的目标矩形渲染循环里用 viewport 切到对应位置。9 路切 4 路只是改 rows 和 cols纹理不用动。注意解码线程完全不知道布局存在它只往 FrameBuffer 里写帧这样布局怎么切都不会丢帧。4.2 定时截图与手动截图并存文件名、目录与磁盘 IO安防场景的截图通常分两种手动抓拍和定时轮巡。手动截图要求响应快点击后 100ms 内出图定时截图要求稳定5 秒一张连跑几小时不出错。两种共用同一个 SaveFrame区别只在触发端。// 定时截图服务独立线程避免 Timer 回调积压 public void StartScheduledCapture(int intervalSeconds) { _captureThread new Thread(() { while (!_cancelled) { Thread.Sleep(intervalSeconds * 1000); string dir Path.Combine(_saveRoot, DateTime.Now.ToString(yyyyMMdd)); Directory.CreateDirectory(dir); foreach (var session in _sessions) { string file Path.Combine(dir, ${session.CameraId}_{DateTime.Now:HHmmss_fff}.jpg); SaveFrame(session, file); // 串行写盘避免并发磁盘争抢 } } }) { IsBackground true }; _captureThread.Start(); }每个通道一个时间戳文件命名里包含 cameraId 和毫秒保证同一秒内手动和定时截图也不会重名。写盘用串行方式SSD 上 9 张 1080P JPEG 大约几百毫秒完全够用。如果摄像头数量超过 16 路再改成生产者消费者队列解码线程只投递请求专门一个写盘线程消费防止磁盘 IO 拖垮解码。4.3 掉线与重连状态面板怎么显示多路摄像头长期跑网络抖动或摄像头重启是常态。解码线程里 av_read_frame 返回错误或超时后需要把会话标记为 DisconnectedUI 线程每秒扫一次状态对掉线会话做黑屏遮罩并显示“重连中”。// 重连策略指数退避最多重试 5 次后固定间隔 int retryCount 0; while (!_cancelled !IsConnected) { retryCount; int delay Math.Min(30, 2 retryCount) * 1000; // 2s - 4s - 8s - 16s - 30s Thread.Sleep(delay); try { StartDecodeLoop(); // 重新执行 avformat_open_input retryCount 0; } catch (Exception ex) { UpdateStatus(ex.Message); } }重连逻辑放在独立线程里绝不放 UI 线程否则掉线时界面会卡死。注意每次重连前必须把旧的 AVFormatContext 完整释放否则 FFmpeg 内部句柄会泄漏。状态面板显示帧率和连接状态帧率用 FrameIndex 差值除以统计时间间隔算每秒刷新一次能直观判断是解码卡了还是网络卡了。5. 多路预览避坑实录卡顿、花屏、截图黑屏5 个现象逐一排查5.1 现象第 5 路开始画面集体掉帧CPU 占用飙到 100%原因每个摄像头单独开了一个 D3D11 设备或者解码和渲染都堆在 UI 线程多路同时工作时线程上下文切换开销翻倍。解决全系统只创建一个 D3D11 设备和 device context所有 CameraSession 共用。解码线程和渲染线程彻底分离解码线程做完 avcodec_receive_frame 和 sws_scale 后立刻结束本轮不碰任何 UI 对象。实测同样 9 路 1080P共用设备比每路独立设备降低约 30% 的 CPU 占用。5.2 现象画面偶尔撕裂出现斜向分割线原因渲染线程读共享纹理时解码线程正在写。没有 KeyedMutex 保护时纹理中间部分是新帧边缘还是旧帧。解决严格按照 3.2 的流程写纹理前 AcquireSync(0)写完后 ReleaseSync(1)渲染线程绘制前也 AcquireSync(0)绘制完 ReleaseSync(1)。注意所有 D3D11 调用必须在同一个 device context 上执行不要混用。这个坑第一天没踩跑了两小时才偶然复现排查全靠把 KeyedMutex 加回来对比。5.3 现象截图全黑或全绿偶尔还是旧画面原因截图的像素来源选错了。有人直接调用 BitBlt 截窗口 DC窗口被其他程序遮挡时截到的是遮挡内容还有人从纹理 GetData 拷贝但纹理被解码线程中途覆盖拿到半帧。解决截图永远走 FrameBuffer 的 GetSnapshot。它保存的是完整 BGRA 数组引用和 GPU 纹理无关。加一个 FrameIndex 判断如果需要截图时发现版本号还没更新就等下一帧而不是立即保存。全绿画面通常是 YUV420P 转 BGRA 时 linesize 没处理对齐导致的记得 sws_scale 的目标 stride 用 width * 4不要用 frame-linesize[0]。5.4 现象摄像头断电后解码线程卡住不退出程序关不掉原因av_read_frame 在 RTSP 连接断开后不会立刻返回错误默认可能阻塞很久。如果没有设置超时线程永远停在阻塞调用里。解决两个手段缺一不可。一是 av_dict_set 设置 stimeout3 秒无响应即返回错误码二是注册中断回调用 Thread.Sleep 或 ManualResetEvent 定期检查退出标志每 500ms 返回一次非零值强制 av_read_frame 退出。这个坑的特点是表面看是 UI 卡死实际是后台线程没退进程不能正常释放。5.5 现象跑 48 小时后句柄数不断增长内存只涨不降原因FFmpeg 的 AVPacket、AVFrame 没释放或者 D3D11 纹理创建后没有 Dispose。这类问题最容易出现在重连路径上重连一次泄漏一批资源积累到几百次后程序彻底崩溃。解决养成固定释放顺序的习惯。av_packet_unref 每个 packet 循环里必调退出时 av_packet_free、av_frame_free、sws_freeContext、avformat_close_input。C# 侧纹理用 using 或实现 IDisposable析构里释放 KeyedMutex 和 Texture2D。我一般会在重连函数里写日志打出资源计数薅一次就能看到哪个对象没释放。6. 最后一个技巧丢帧策略与延迟控制多路跑久了也不漂很多人以为多路预览卡是因为解码慢其实多数情况下是渲染线程追不上解码帧率导致帧积压、延迟越来越大。解决这个问题的核心原则是只拿最新帧绝不逐帧绘制。解码线程每产生一帧就给 FrameIndex 自增。渲染线程记录上一次渲染的帧号本次检查 FrameBuffer 的当前帧号如果发现跳过了很多帧只画最新那一帧。这样解码 30 FPS、渲染只有 20 FPS 时实际画面依然是接近实时的只是跳过了部分中间帧观感上丝滑流畅。相反如果渲染线程努力跟上每一帧一旦 UI 抖动一下积压的帧会突然全部追上来画面像按了快进。验证延迟有一个很笨但有效的办法在摄像头前放一个手机打开秒表应用对比预览画面和真实秒表的读数差异。多路布局下每路延迟可能不同重点看差距是否稳定。如果某一路的延迟持续增长基本可以断定该路解码线程被其他路的 CPU 占用抢了资源调整线程优先级或者减少该路解码分辨率。最后分享一个习惯每路 CameraSession 启动时先读一次 SPS/PPS 信息记录分辨率、编码格式、帧率。掉线重连后如果分辨率变化自动重建 FrameBuffer 和纹理而不是假定所有摄像头参数永远一致。这套方案最大的价值并不是某一帧的画质有多好而是长时间运行不崩、延迟可控、截图不依赖屏幕。希望帮到你。本文还有配套的精品资源点击获取