简介面向需要在C#项目中集成Onvif协议的开发者这份Demo程序提供了一套亲测可用的摄像机对接方案涵盖RTSP视频流地址获取、PTZ云台控制、预置位设置与调用并借助VLC 3.0.4.0实现视频流播放同时附带获取RTSP地址的Web API接口可快速接入既有业务系统适合具备一定C#基础、需要对接主流Onvif摄像机的开发人员。资源包共2748个文件压缩后约357MB核心包含C#工程源码cs、sln、csproj及编译产物dll另有大量辅助依赖、XML配置、HTML文档等便于直接编译调试或按需提取功能模块。已有1868人学习下载。通过该资源开发者可省去自行抓包分析Onvif协议细节的繁琐工作直接参考DEMO中的服务代理、控制命令封装与播放器对接逻辑快速完成摄像机的预览和云台联动开发。1. 用C#对接Onvif摄像机把视频流、PTZ云台控制和预置位串成一套能用的客户端最劝退的往往不是C#语法而是ONVIF协议栈的入口太多设备发现、媒体服务、PTZ服务各管一段稍有不顺就卡在认证或服务地址上。这篇文章讲一条完整落地链路用WS-Discovery或手动配置拿到设备服务地址通过GetProfiles和GetStreamUri取出可播放的RTSP源用C#封装PTZ连续移动和预置位存取最后交给VLC播放网络视频源。适合正在做安防客户端、设备对接或中控系统的C#开发者也适合刚接手这类项目、被厂商SDK文档绕晕的人。内容按真实踩坑记录来讲代码可直接抄关键参数会说明为什么这么设。2. 从发现设备到拿到RTSP取流地址WS-Discovery与GetStreamUri的完整链路2.1 两种拿设备地址的姿势UDP组播探测与手动指定生产环境我更推荐哪个ONVIF不是一套SDK而是一组基于SOAP的Web服务。一台摄像机至少暴露两个入口设备管理服务Device Service和媒体服务Media Service有的厂商还把PTZ服务单独挂一个路径。它们的地址形如http://ip/onvif/device_service、http://ip/onvif/media_service但前缀和路径命名在各厂商之间差异很大有的用/onvif/mediasrv有的用/v2_0/MediaService。所以对接第一步永远不是写业务代码而是先拿到一份可靠的“服务地址列表”。常见做法有两种。第一种是手动指定界面里让用户填设备IP、端口程序按约定的几个路径逐个试探能用就缓存下来。设备数量少、网络拓扑固定的项目我一般就用这个省事、可控。第二种是WS-Discovery组播探测向239.255.255.250:3702发一条Probe报文设备回包里的XAddrs节点就是它的服务地址。代码如下// 向 ONVIF 标准的 WS-Discovery 组播地址发 Probe探测网络里的摄像机 using var udp new UdpClient(); udp.EnableBroadcast true; var probe ?xml version1.0? e:Envelope xmlns:ehttp://www.w3.org/2003/05/soap-envelope xmlns:wsahttp://www.w3.org/2005/08/addressing xmlns:dhttp://schemas.xmlsoap.org/ws/2005/04/discovery xmlns:dnhttp://www.onvif.org/ver10/network/wsdl e:Header wsa:Actionhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe/wsa:Action /e:Header e:Body d:Probe d:Typesdn:NetworkVideoTransmitter/d:Types /d:Probe /e:Body /e:Envelope; var bytes Encoding.UTF8.GetBytes(probe); await udp.SendAsync(bytes, bytes.Length, 239.255.255.250, 3702); // 设备回包通常有几百毫秒到几秒延迟要循环接收并解析 d:XAddrs using var cancel new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!cancel.IsCancellationRequested) { var resp await udp.ReceiveAsync(); var xml Encoding.UTF8.GetString(resp.Buffer); // 用 XDocument 解析 XAddrs取第一个 http 地址作为 Device Service 入口 }参数说明组播地址和端口是ONVIF规范定死的不能改dn:NetworkVideoTransmitter是设备类型过滤条件写成dn:NetworkVideoDisplay也能发现部分显示设备但对接摄像机用前者最稳。需要注意组播探测在跨VLAN时基本失效很多园区网还会过滤组播包所以它更适合做“自动发现兜底”而不是唯一手段。我现在的习惯是手动维护设备IP清单作为主通道开机时异步跑一次组播把发现到的新设备加到清单里既稳定又不用一台台手工录。2.2 GetProfiles→GetStreamUri→RTSP三步取流的调用顺序与返回结构拿到设备服务地址后取视频流要走媒体服务而不是设备服务。这一步很多人第一次就搞混在Device Service里找不到取流的接口回头查文档才发现有两个WSDL。取流的调用顺序是固定的三步先GetProfiles拿到这台设备支持的所有视频配置再从里面挑一个带编码配置的MediaProfile最后拿这个profile的token去调GetStreamUri。示例代码// mediaClient 是通过 svcutil/media.wsdl 生成的 WCF 代理EndPoint 指向媒体服务地址 var profileResp await mediaClient.GetProfilesAsync(new GetProfilesRequest()); var profile profileResp.Profiles .FirstOrDefault(p p.VideoEncoderConfiguration ! null); // 多数设备默认返回两个 profile一个主码流一个子码流都带着各自的 token var setup new StreamSetup { Stream StreamType.RTPUnicast, // 单播组播很多设备要单独开不推荐 Transport new Transport { Protocol TransportProtocol.RTSP } }; var uriResp await mediaClient.GetStreamUriAsync( new GetStreamUriRequest(setup, profile.token)); Console.WriteLine(uriResp.MediaUri.Uri); // 输出形如rtsp://192.168.1.64:554/Streaming/Channels/101这里有两个参数值得说透。StreamType用RTPUnicast兼容性最好局域网里没有任何理由先碰组播Transport.Protocol明确成RTSP大部分设备要求的都是这个值个别老设备不填会直接报InvalidArgVal。另外取回来的RTSP地址里通常不带用户名密码这段URL不要自己去拼账号VLC播放时单独传原因后面讲。用哪个profile也有讲究要选VideoEncoderConfiguration非空的那个纯PTZ配置的profile里没有视频流拿它的token去取流会拿到一个空地址。判断主码流和子码流看Resolution的宽高就行。3. C#封装PTZ云台控制与预置位从WSDL生成代理到命令参数3.1 用svcutil把ONVIF的PTZ WSDL转成C#代理再包一层自己的PtzClientONVIF的PTZ控制接口定义在标准WSDL里常见做法是先把WSDL转成C#代理类业务代码不直接碰SOAP细节。我用的是.NET官方工具命令如下# 从ONVIF规范页下载 ptz.wsdl 和 media.wsdl 后用工具生成代理 dotnet-svcutil ptz.wsdl -o ./OnvifProxy/Ptz dotnet-svcutil media.wsdl -o ./OnvifProxy/Media # 生成的目录里包含服务契约和客户端类直接加入解决方案生成之后不要裸着用我会再包一层薄壳把认证、超时、profile token这些公共逻辑收进来。注意ONVIF走的是WS-Security UsernameToken认证WCF默认绑定不支持这种摘要认证方式需要手动构造CustomBinding并给每次请求附加UsernameToken安全头。下面这个类就是简化后的样子public class OnvifPtzClient { private readonly PtzClient _inner; // svcutil 生成的 WCF 客户端 private readonly string _profileToken; public OnvifPtzClient(string ptzUrl, string profileToken) { var binding new CustomBinding(new HttpTransportBindingElement()); // 认证头在调用前通过 IEndpointBehavior 注入 _inner new PtzClient(binding, new EndpointAddress(ptzUrl)); _profileToken profileToken; } public async Task MoveAsync(float pan, float tilt, float zoom) { // X/Y/Zoom 取值 -1.0 ~ 1.00 表示该轴不动 var req new ContinuousMoveRequest(_profileToken, new PTZSpeed { X pan, Y tilt, Zoom zoom }); await _inner.ContinuousMoveAsync(req); } public async Task StopAsync() { // 两个 bool 分别表示停止水平/垂直运动、停止变焦 await _inner.StopAsync(new StopRequest(_profileToken, true, true)); } }参数说明PTZSpeed里的 X 对应水平、Y 对应垂直、Zoom 对应变焦范围都是 -1.0 到 1.0正负代表方向。StopRequest的两个 bool 参数按业务需要组合云台和变焦电机是独立的只想停转动、保留变焦时可以传(true, false)。3.2 ContinuousMove连续移动与Stop速度参数的范围与坐标方向坑ContinuousMove是“按住才动、松开就停”的连续运动模式和AbsoluteMove那种“给个绝对坐标直接走过去”不一样。做方向键控制时用前者手感最自然但坐标系方向是个大坑ONVIF标准定义X正方向为向右、Y正方向为向上可实际设备有的按球机安装朝向算有的按图像坐标算发(0.3, 0, 0)镜头往哪边动完全取决于厂商实现。这块在项目里被当成玄学调过好几天。我现在的习惯是对接任何新设备先跑一遍“点动测试”——发一个(0.2, 0, 0)停300毫秒记录镜头实际方向再发(-0.2, 0, 0)对比Y轴同理。测试结果存进设备的配置文件里形成一个方向映射表业务层发方向键时按映射做正负翻转。速度参数本身也要注意0.2到0.4在大多数球机上已经是比较快的转速0.1适合微调超过0.8某些机械云台会直接甩过头。ContinuousMove发完之后如果检测到超时或断线务必补发一次Stop否则云台会一直转到物理限位这是最常见的“云台自己跑了”事故来源。3.3 SetPreset与GotoPreset预置位编号、名称与越界行为预置位逻辑比云台移动简单但有两个边界情况必须在代码里处理。先看调用public async Taskstring SavePresetAsync(int number, string name) { // 传 ReferenceToken 表示期望的编号部分设备会重新分配 var req new SetPresetRequest(_profileToken, new ReferenceToken(number.ToString()), name); var resp await _inner.SetPresetAsync(req); // 返回值里的 PresetToken 才是设备真正分配的编号必须用它做后续调用 return resp.PresetToken; } public async Task GotoPresetAsync(string presetToken, float speed 0.5f) { var req new GotoPresetRequest(_profileToken, new ReferenceToken(presetToken), new PTZSpeed { X speed, Y speed, Zoom speed }); await _inner.GotoPresetAsync(req); // 不要固定 sleep轮询 GetStatus 直到 MoveStatus 都处于 IDLE while (!(await IsPtzIdleAsync())) await Task.Delay(300); }第一坑SetPreset请求里的编号只是“期望值”设备不一定接受。有些厂商的设备只支持1到8你传9它要么静默拒绝要么返回错误有些设备会重新分配编号并放进返回结构里。所以保存后一定要以返回的PresetToken为准把它和界面编号的映射存进自己的配置表。第二次调用GotoPreset时如果拿错编号会出现“保存成功但就是回不去”的怪现象。第二坑GotoPreset的 Speed 是倍率不是绝对速度0.5表示以中速走过去传0或负数可能导致部分设备直接忽略命令。云台到位判断不要用固定延时机械结构的启停延迟差异很大轮询GetStatus看运动状态才是可靠做法。4. 让VLC播放网络视频源LibVLCSharp接入RTSP的配置与编码兼容4.1 播放器选型LibVLCSharp、VLC.ActiveX与系统自带播放器视频流拿到以后播放环节有三个选择我直接给对比方案集成方式RTSP兼容性维护状态适用场景LibVLCSharpNuGet包跨平台好TCP/UDP可配活跃WPF/WinUI/跨平台项目VLC.ActiveXCOM组件拖入WinForms好基本停更老项目快速改造MediaPlayerElement系统自带差H.265常不通随系统更新不推荐做安防播放LibVLCSharp是VLC的C#绑定我最推荐。它把VLC内核封装成C# APIRTSP、H.264、H.265都能解还能在播放参数上做精细控制比如强制TCP传输、调缓存。VLC.ActiveX在WinForms里拖进去确实快但兼容性在64位系统和DPI缩放下问题不少新项目别踩这个坑。系统自带的MediaPlayerElement对RTSP支持最弱H.265基本放不了做安防客户端直接排除。4.2 RTSP的tcp/udp传输、网络缓存与用户名密码LibVLCSharp接入示例接入代码不长但每个选项都有讲究using LibVLCSharp.Shared; // 初始化时把公共选项传进去--rtsp-tcp 强制走 TCP 传输 var libVlc new LibVLC(--no-video-title-show, --rtsp-tcp); // RTSP URL 里不带账号密码用独立选项传入避免日志泄露 var media new Media(libVlc, new Uri(rtsp://192.168.1.64:554/Streaming/Channels/101)); media.AddOption(:network-caching300); // 毫秒跨网段调到 1000 media.AddOption(:rtsp-username userName); media.AddOption(:rtsp-password password); var player new MediaPlayer(libVlc); player.Media media; player.Play();--rtsp-tcp是这条链路里最重要的一个参数。摄像机默认的RTSP over UDP在局域网里丢包一多就花屏、卡顿强制TCP后用可靠传输换流畅度代价只是延迟略增安防预览场景完全可以接受。network-caching控制网络缓冲局域网内300毫秒够用预览延迟大约在400ms左右如果走公网或Wi-Fi桥接调到800到1000更稳但云台操作后的画面反馈会明显变慢属于典型的“延迟换流畅”要根据网络质量取舍。用户名密码单独用rtsp-username传而不是拼在URL里是因为很多播放器内核会把完整的URL写进日志带账号的URL等于把密码直接打在了日志里这个习惯我踩过一次之后就不再犯了。4.3 编码兼容与黑屏排错H.264/H.265和子码流的常见翻车点VLC挂了但黑屏先别急着怀疑代码百分之七十是编码和码流选择问题。H.265HEVC的摄像头越来越普遍但并不是所有VLC发行版都内置了H.265解码器尤其是一些精简版播放器组件。判断方法很简单播放时看任务管理器里CPU有没有明显爬升如果CPU几乎没动而画面黑屏多半是解码器没起来。解决办法是换用带完整解码能力的VLC内核包或者从摄像机那边把编码改成H.264测试确认问题在解码还是传输。另一个常见翻车点是子码流分辨率过低很多摄像机默认子码流是352x288或640x360放大到窗口后全是马赛克这不是代码问题是选错了profile。调试时务必先抓主码流确认画面质量再决定业务上用哪路流。5. Onvif对接避坑指南时间同步、认证失败与厂商差异的排查记录5.1 现象ONVIF认证失败设备返回5010或401现象程序调用GetProfiles、GetStreamUri时设备直接抛SOAP Fault错误码5010或者返回401 Unauthorized。日志里看认证头已经带了就是验不过。原因ONVIF的UsernameToken认证用的是摘要算法服务端拿设备当前时间和随机数参与计算设备时钟和你电脑时钟差太多时摘要对不上必然失败。很多摄像机出厂后时间就不准或者时区设置错误这类问题在新接设备时高频出现。解决第一步先做时间同步通过ONVIF的SetSystemDateAndTime把设备时间设成和客户端一致或者直接给设备配置NTP服务器。这里有个细节有些设备要求UTC时间SetSystemDateAndTime传本地时间加时区偏移时容易算错我一般直接传UTC避免歧义。第二步是检查生成代理时的SOAP版本个别设备只认SOAP 1.1WCF默认的1.2会一直报协议错误表现也是认证失败。5.2 现象PTZ命令发出去没反应ContinuousMove不生效现象ContinuousMoveAsync调用返回成功但云台纹丝不动。反复试不同速度值也一样。原因多数情况是profile里根本没有PTZ配置。你用GetProfiles拿到第一个profile但那个profile可能只包含视频编码配置不含PTZConfiguration把PTZ命令绑在没有云台配置的profile上设备直接当作无效命令吞掉。另一个原因是没有正确传ProfileToken或者设备要求先发一个绝对值移动来“激活”云台。解决接新设备先调GetConfigurations看 PTZConfiguration 是否非空再调GetProfiles确认哪个profile里带PTZConfiguration的引用两处对得上才继续。调试时我习惯先用厂商自己的客户端或ONVIF设备管理工具动一下云台确认硬件本身没问题再回来查代码这样能把“协议问题”和“硬件问题”快速切分开。5.3 现象VLC画面黑屏、卡缓冲、只有声音没有画面现象VLC能连上RTSP声音正常但画面黑屏或者每隔几秒转圈缓冲一次。原因先分两层看。只有声音没画面基本是解码器问题H.265视频流没有对应解码器时VLC会退化成只解音频频繁缓冲则是传输或缓存问题UDP模式丢包、网络缓存太小都会导致播放器不断等待数据。还有一类是认证问题VLC播放时账号密码错误时会反复重连表现也是黑屏加循环缓冲但日志里会打印401。解决播放参数里强制--rtsp-tcp并把network-caching提到500以上先排除传输问题确认编码格式后换完整版VLC内核最后打开VLC的消息面板看有没有401或403有就回头检查用户密码和ONVIF鉴权配置。调试时我会把这三个排查项固定成脚本每次接新设备直接跑一遍能省不少时间。5.4 现象预置位保存成功GotoPreset却回不到原位现象界面提示保存成功预置位列表也有记录但调用GotoPreset后云台纹丝不动或走到错误位置。原因最常见的两类。一是编号越界设备支持的预置位数量有限很多球机只支持8个你传了第9个保存时设备可能静默返回成功但内部没存二是保存和调用用的token不一致SetPreset返回的PresetToken和请求里传的编号不同下次调用还在用你自己定义的编号。解决保存后立即调GetPresets拉一遍设备当前实际存的预置位列表以返回列表里的PresetToken为准重建本地映射。越界问题在保存时就校验先GetPresets看当前数量和最大编号超过上限直接不让用户保存。这个习惯帮我在某项目上避免了一次现场返工8个点位用满后新增点位全部无效的问题就是这么排查出来的。5.5 现象同一台设备三个服务地址用错一个全盘报错现象GetProfiles能通但ContinuousMove调用报NotImplemented或ActionNotSupported换域名、换IP都没用。原因Device、Media、PTZ三个服务在设备上不一定是同一个HTTP路径有些厂商还把PTZ服务放在独立端口。用媒体服务地址去调PTZ接口或者用设备服务地址去调媒体接口自然报“不支持的Action”。解决不要猜路径启动时调一次GetServices把返回的每个服务名和地址拉出来建一张“服务地址映射表”后续所有调用按服务类型查表。代码里凡是出现硬编码的/onvif/device_service路径都要警惕厂商差异规范允许厂商自定义路径后缀只是大多数默认相同而已。接到新设备先打印这张表再继续等于给自己留了一颗后悔药。6. 把发现、取流、云台、预置位串成一个调试面板最后一公里的验证技巧写C#对接ONVIF代码本身不难难的是出事时定位慢。我最后一公里通常花在做这样一个调试面板上左侧设备列表中间VLC预览右侧云台方向键和预置位表格。面板启动时做一件事——把服务树、能力、profile、预置位全部拉出来打到一个日志窗格里像这样调试命令作用出问题时的表现GetServices列出所有服务地址地址缺失厂商路径差异GetCapabilities看设备支持哪些能力PTZ能力为false不用调PTZGetProfiles列出所有媒体配置无编码profile取不到流GetPresets列出实际预置位编号不连续越界或手动删过GetStatus看云台当前运动状态一直MOVING停止命令没生效方向键控制接上点动测试逻辑后把每个设备的方向映射存成JSON新设备接入时先跑一轮自动点动更新映射文件即可业务层永远不用改。这套流水线跑通之后对接一台新摄像机从插网线到能看到画面、云台能转、预置位能存能调通常控制在一小时内。做这类项目我有一条血泪经验永远不要在不知道设备服务地址表的情况下开始写业务代码先让面板把该打印的都打印出来黑匣子只有在被扒开时才不可怕。希望帮到你。本文还有配套的精品资源点击获取