简介:一套基于C#的海康工业相机双机连接示例工程,采用WinForms界面,面向工业视觉开发者、自动化设备调试人员和C#硬件交互学习者,目标是解决两台相机同步连接、独立参数配置与图像采集的常见问题。资源共48个文件,压缩包约33.54MB,工程内包含9个C#源文件(.cs)、7个动态库(.dll)、3个可执行文件(.exe),另有解决方案、配置文件、资源文件、调试符号及日志等,类型覆盖源码、依赖库、编译产物和项目配置,结构清晰,便于直接打开工程调试运行。已有2852人学习下载。读者可据此快速理解海康SDK的调用流程,掌握设备枚举、连接、参数设置、图像抓取、多线程处理以及界面绑定等完整实现,同时还能借鉴多相机同步和数据缓冲的写法,适合直接套用于自动化检测或视觉定位项目;附带调试符号与日志,也便于定位相机连接异常,减少开发踩坑。
1. 项目概述:双相机连接这件事,到底难在哪
先说结论:用C#连接两台海康工业相机,本身不是一个“从零造轮子”的活,真正的难点在于搞清楚SDK的调用套路、资源管理的边界,以及两台相机并发取流时的调度策略。这个项目我在实际产线上做过,也帮朋友排查过类似问题,这里把完整思路和踩坑记录整理出来,希望对做上位机开发的朋友有帮助。
这个需求的典型场景很明确:一条流水线上,一台相机拍产品正面,另一台拍背面;或者一台负责定位,一台负责读码。两台相机如果接到同一台工控机上,就需要上位机软件能同时枚举、打开、取流、回调处理两路图像。用C#来实现,是因为C#在WinForm/WPF下做界面、做通信、对接数据库的效率确实高,开发周期短,后期维护也方便。适合谁来参考?正在用海康MVS SDK做上位机开发的人,尤其是第一次接触多相机项目、对SDK调用流程还不够熟悉的C#工程师。
我用的方案是海康机器人官方的MVS(Machine Vision Software)软件配套的SDK,C#封装在MvCameraControl.Net这个命名空间下。整体流程说白了就四步:枚举设备、创建句柄并打开、注册图像回调、开始取流。两台的逻辑跟一台几乎没有区别,区别只在于“区分设备”和“并发处理”这两个环节。
2. 核心设计思路:为什么两条取流链路必须独立
2.1 双相机的连接方式选择:USB3.0还是千兆网
海康工业相机主流接口有两种:GigE(千兆网口)和USB3.0。做双相机方案时,接口选型直接决定代码写法,因为SDK枚举设备时返回的MV_CC_DEVICE_INFO结构体里的传输层类型(nTLayerType)不一样,取流逻辑也不同。
- GigE相机:走网口,需要配置IP地址,能用PoE供电,传输距离远,适合相机离工控机比较远的产线。
- USB3.0相机:走USB口,供电和数据共用一根线,带宽上限高(理论5Gbps),但线缆长度限制在3米以内,抗干扰能力弱一些。
- 混合接法:一个网口一个USB口,也是允许的,SDK枚举时两种类型都会返回。
我的建议是:如果现场没有特殊要求,优先用GigE。原因很实际——千兆网卡在工控机上通常有两个口,两个相机各插一个,可以避免共享带宽导致的丢帧。USB3.0虽然带宽高,但如果两个USB相机插在同一个USB控制器下,争抢带宽的问题会很头疼。而网口的IP配置相对独立,一个网卡配192.168.1.x,另一个配192.168.2.x,逻辑上也好区分。
2.2 取流模式:回调模式才是多相机项目的正确选择
海康SDK取流有几种方式,很多新手一上来就纠结。简单归类:
| 方式 | 说明 | 适用场景 |
|---|---|---|
| 主动拉流 | 循环调用MV_CC_GetImageBuffer获取图像 | 单相机、逻辑简单的小工具 |
| 回调模式 | SDK内部线程收到图像后自动触发回调函数 | 多相机、实时性要求高的产线项目 |
| 硬触发 + 回调 | 外部信号触发相机曝光,曝光完成后通过回调通知上位机 | 运动控制同步、飞拍、高速检测 |
双相机项目不要用主动拉流。两个相机各开一个线程去循环取图,不是不行,但代码复杂度高,还要自己处理缓冲区和超时问题,很容易出隐蔽Bug。我实测下来的最优解是:每个相机注册一个独立的回调函数,SDK底层用独立线程取流,回调里只做“把图像Buffer拷贝出来丢进队列”这件事,处理逻辑放到工作线程里。这样做的好处是取流线程永远不会被业务逻辑阻塞,相机端不丢帧。
2.3 资源模型:句柄、回调、缓冲区的生命周期管理
这是整个项目最容易出问题的地方。海康SDK的C#封装底层还是P/Invoke调用C++接口,所以资源管理必须非常小心。核心资源有这么几个:
- 设备句柄(
MyCamera.MV_CC_HANDLE):每台相机一个句柄,创建后用MV_CC_OpenDevice打开,程序退出前必须MV_CC_CloseDevice+MV_CC_DestroyHandle,顺序不能反。 - 图像回调:注册回调后,SDK会为每个相机维护内部取流线程,如果
MV_CC_StopGrabbing不调用就释放句柄,线程可能会悬空。 - 图像数据缓冲区:回调里拿到的
MV_FRAME_OUT_INFO_EX指向的图像Buffer是SDK内部内存,不能保存引用,必须马上拷贝到自己申请的托管数组或byte[]里。
我见过太多新手在回调里直接把pData指针存到全局变量,或者把IntPtr转成Bitmap后不释放,跑一晚上内存直接爆掉。这里的关键认知是:SDK给你的是一个“临时借用的内存”,不是“送你的内存”,用完必须立即拷走。
3. 双相机连接与并发取流的完整实现
3.1 环境准备:SDK获取与引用
去海康机器人官网下载MVS安装包,安装后开发包里自带C#示例。这里注意,MVS安装完有两个东西:一个是调试用的客户端软件(用来手动调焦、测试图像),另一个是SDK开发包,两者都有用。开发时只需要在C#工程里引用两个东西:
MvCameraControl.Net.dll:SDK的C#封装MvCameraControl.dll:底层C接口dll,运行时需要放在exe同目录
如果你用NuGet,搜MvCameraControl.Net也能直接装,省去手动复制dll的麻烦。但我的建议是直接用MVS安装目录下的dll,因为版本匹配不会出幺蛾子。装完MVS后,把dll引用进来,代码里using MvCamCtrl.NET;就能用了。
3.2 双相机枚举与设备信息打印
枚举这一步是所有操作的前提,也是排查问题最常用的手段。核心代码其实很短:
using MvCamCtrl.NET; // 枚举设备 MyCamera.MV_CC_DEVICE_INFO_LIST deviceList = new MyCamera.MV_CC_DEVICE_INFO_LIST(); int nRet = MyCamera.MV_CC_EnumDevices(MyCamera.MV_GIGE_DEVICE | MyCamera.MV_USB_DEVICE, ref deviceList); if (nRet != MyCamera.MV_OK || deviceList.nDeviceNum == 0) { Console.WriteLine("未枚举到设备,请检查网线/USB线连接"); return; } // 遍历设备信息 for (uint i = 0; i < deviceList.nDeviceNum; i++) { IntPtr pDeviceInfo = Marshal.UnsafeAddrOfPinnedArrayElement(deviceList.pDeviceInfo, (int)i); MyCamera.MV_CC_DEVICE_INFO deviceInfo = (MyCamera.MV_CC_DEVICE_INFO)Marshal.PtrToStructure(pDeviceInfo, typeof(MyCamera.MV_CC_DEVICE_INFO)); if (deviceInfo.nTLayerType == MyCamera.MV_GIGE_DEVICE) { MyCamera.MV_GIGE_DEVICE_INFO gigeInfo = (MyCamera.MV_GIGE_DEVICE_INFO)Marshal.PtrToStructure( deviceInfo.SpecialInfo.stGigEInfo, typeof(MyCamera.MV_GIGE_DEVICE_INFO)); Console.WriteLine($"相机{i}: GigE, IP={gigeInfo.nCurrentIp}, 厂商={gigeInfo.chManufacturer}"); } else if (deviceInfo.nTLayerType == MyCamera.MV_USB_DEVICE) { MyCamera.MV_USB_DEVICE_INFO usbInfo = (MyCamera.MV_USB_DEVICE_INFO)Marshal.PtrToStructure( deviceInfo.SpecialInfo.stUsb3VInfo, typeof(MyCamera.MV_USB_DEVICE_INFO)); Console.WriteLine($"相机{i}: USB3.0, 序列号={usbInfo.chSerialNumber}"); } }这段代码里比较讲究的是Marshal.UnsafeAddrOfPinnedArrayElement的用法。deviceList.pDeviceInfo本身是IntPtr数组,里面存的是每个设备信息结构体的内存地址,要用PtrToStructure抠出来。枚举后的设备序号(deviceList里的索引)在后续打开设备时是要用到的,所以一定要打印出来确认哪台是哪个索引。实际项目里为了稳妥,我会让用户通过界面下拉框选择“相机1对应序号几、相机2对应序号几”,而不是写死,因为网口相机枚举顺序不保证固定。
3.3 打开两台相机并分别设置参数
枚举到两台相机后,分别创建句柄、打开设备、设置参数。这里必须为每台相机准备一个独立的实例,不能共用同一个MyCamera对象。我习惯用一个简单的类把核心操作包起来:
public class HikCamera { private MyCamera _camera = new MyCamera(); private string _deviceKey; // GigE用IP标识,USB用序列号标识 private nint _handle = IntPtr.Zero; public bool Open(string deviceKey) { _deviceKey = deviceKey; // 枚举找到匹配的设备 MyCamera.MV_CC_DEVICE_INFO_LIST deviceList = new MyCamera.MV_CC_DEVICE_INFO_LIST(); MyCamera.MV_CC_EnumDevices(MyCamera.MV_GIGE_DEVICE | MyCamera.MV_USB_DEVICE, ref deviceList); for (uint i = 0; i < deviceList.nDeviceNum; i++) { IntPtr pDeviceInfo = ...; // 取设备信息 string key = GetDeviceKey(pDeviceInfo); // 统一提取IP或序列号 if (key == deviceKey) { _camera.MV_CC_CreateHandle(ref _handle, pDeviceInfo); int nRet = _camera.MV_CC_OpenDevice(_handle); return nRet == MyCamera.MV_OK; } } return false; } }参数设置方面,产线上最常改的是这几个:触发模式(TriggerMode,工业检测一般设成硬件触发,实验室调试设成连续采集)、曝光时间(ExposureTime,微秒为单位)、像素格式(PixelFormat,BayerRG8/G16之类的)、帧率上限(AcquisitionFrameRate)。两个相机参数可能是不同的,比如一台曝光500微秒拍正面,另一台曝光2000微秒拍暗光背面,所以设置参数时一定要确保是在对应的句柄上操作。这个环节常见的低级错误就是把参数设置代码写在枚举循环里,结果两个相机被设置成了一样的参数。
3.4 注册图像回调并启动取流
这是整个项目的核心环节。海康SDK注册回调的函数是MV_CC_RegisterImageCallBack,C#里传入一个委托。注意委托不能被GC回收,所以要声明成类的成员变量,否则运行一段时间后SDK调用回调时可能会因为委托被回收而崩溃。
回调注册的完整流程:
// 定义回调委托,签名必须是这样 private MyCamera.cbOutputExdelegate _imageCallback; public void StartGrabbing() { // 回调里只做拷贝,把图像数据塞进队列 _imageCallback = new MyCamera.cbOutputExdelegate(OnImageCallback); _camera.MV_CC_RegisterImageCallBack(_handle, _imageCallback, _handle); // 设置触发模式为连续采集(调试用) _camera.MV_CC_SetEnumValue(_handle, "TriggerMode", 0); // 开始取流 int nRet = _camera.MV_CC_StartGrabbing(_handle); if (nRet != MyCamera.MV_OK) { Console.WriteLine($"启动取流失败, 错误码=0x{nRet:X8}"); } } private void OnImageCallback(IntPtr pData, ref MyCamera.MV_FRAME_OUT_INFO_EX pFrameInfo, IntPtr pUser) { // 在回调里立即把图像数据拷贝到托管数组 byte[] buffer = new byte[pFrameInfo.nFrameLen]; Marshal.Copy(pData, buffer, 0, (int)pFrameInfo.nFrameLen); // 丢进线程安全队列,由工作线程处理 _imageQueue.Enqueue(new FrameData(buffer, pFrameInfo.nWidth, pFrameInfo.nHeight)); }这段代码有两点至关重要。第一,回调函数里面绝对不能做耗时操作,比如保存图片、跑算法、更新UI,这些全部要丢到工作线程里。因为SDK的取流线程是共享的,如果回调阻塞了,后面的帧就会丢掉。第二,pFrameInfo.nFrameLen是回调传出来的图像字节数,在拷贝前必须先判断这个值的合法性,别一不小心分配一个超大数组造成内存溢出。
两台的启动逻辑就是分别调用Camera1.StartGrabbing()和Camera2.StartGrabbing(),互不干扰。因为每个HikCamera实例内部都有自己的_camera、_handle、_imageQueue,相当于两条完全独立的取流链路。
3.5 停止取流与资源释放
释放顺序错了,程序退出时必崩。正确的释放顺序是:
public void Close() { // 1. 先停止取流 _camera.MV_CC_StopGrabbing(_handle); // 2. 再关闭设备 _camera.MV_CC_CloseDevice(_handle); // 3. 最后销毁句柄 _camera.MV_CC_DestroyHandle(_handle); _handle = IntPtr.Zero; }注意MV_CC_DestroyHandle之后_handle一定要置零,不然下次打开时如果忘记重新创建句柄,SDK内部会返回错误。另外,如果你的程序里还有其他线程在访问_handle,释放前必须确保这些线程已经退出,或者加锁保护,否则会产生并发访问已释放内存的问题,表现形式就是偶发的AccessViolationException。
4. 常见问题与排查技巧实录
4.1 枚举不到设备,第一步不是查代码
很多人在两台相机连不上时,第一反应是去看代码逻辑,其实这是最耗时间的做法。我实际调试中总结的排查顺序是:先查物理链路,再查SDK枚举,最后才查代码。
物理链路方面,GigE相机要看网卡是否有绿色指示灯、IP是否跟相机在同一网段,可以用海康MVS客户端软件直接连接验证。如果MVS客户端软件能连接相机,但自己的C#程序枚举不到,那问题基本出在dll版本或环境变量上,比如MVS软件没安装、MvCameraControl.dll不在exe目录、或者NuGet包装的SDK版本跟相机固件不兼容。
排查时推荐一个小技巧:用MVS客户端软件把相机工作模式调好(触发模式、曝光时间、分辨率等),然后保存配置文件,C#代码里连接时直接加载配置文件,能省掉大量参数调试时间。
4.2 两个相机连上了,但图像卡顿、丢帧严重
双相机连接后最常见的性能问题就是丢帧。原因无非以下几种。
- GigE相机带宽不足:两个千兆相机共用一张网卡时,总带宽被分成两份,高分辨率高帧率下很容易丢帧。解决办法是给每个相机分配独立网卡,或者降低帧率/分辨率。
- USB带宽争抢:两个USB3.0相机插在同一个USB控制器上,虽然USB3.0理论带宽很高,但控制器内部还是有资源竞争。解决办法是把两个相机插到不同的USB控制器上,通常在设备管理器里可以查到每个相机的“位置路径”。
- 回调里处理太慢:上面说了,回调里千万别做耗时操作。如果你的回调里拷贝大数组(比如500万像素的Bayer图像),拷贝本身耗时很大,也容易拖慢取流。优化方案是用对象池复用
byte[],避免每次回调都重新分配托管数组。
我第二次做双相机项目时吃过一个亏,两个相机都是500万像素、30帧,回调里我做了图像格式转换(Bayer转RGB),结果直接掉到12帧。后来把格式转换挪到工作线程,帧率才恢复正常。这个经验很重要:回调里拷贝数据都要尽量省,最好直接传指针和尺寸给工作线程,由工作线程拷贝。
4.3 相机掉线了怎么办
产线环境里,相机掉线是躲不开的问题。网线松了、PoE供电不稳定、USB被误拔,都可能触发掉线。问题在于SDK的取流线程在掉线时可能会无限等待或抛出异常,导致整个程序卡死。
我采取的兜底方案是:在工作线程里定时通过GetDeviceInfo或主动拉流检测相机状态,超时则执行自动重连。重连逻辑简单粗暴——先执行Close(),再延迟几百毫秒,然后重新枚举设备、重新打开、重新注册回调、重新开始取流。虽然不优雅,但稳定可靠。如果你希望更平滑,还可以用海康SDK的“断线重连”能力,SDK内部会自动尝试恢复链路,但实测下来恢复速度不一定满足产线节拍要求,所以还是自己写重连更可控。
需要注意的是,重连时相机的参数设置(曝光、触发模式、像素格式)会恢复成默认值或掉电保存值,所以重连后必须重新设置一遍参数,否则拍出来的图可能一片黑或者参数不对。这个坑我踩过,特别隐蔽——程序重连成功后看似正常,但曝光时间变成了默认值,图像曝光严重不足。
4.4 程序跑久了内存只涨不降
这个问题的根源基本都在回调函数里。很多人把SDK传来的IntPtr直接转成Bitmap后没有调用Dispose(),或者每次都new byte[]但没有被GC及时回收。对于一个长期运行的产线上位机程序,这些没释放的对象会不断堆积。
排查方法也很直接:用dotnet-counters或者Visual Studio的诊断工具抓内存快照,看byte[]和Bitmap的量是否持续增长。解决思路就三条:
- 回调里尽量少分配对象,用预先申请好的缓冲区池复用。
Bitmap用完立即Dispose(),或者用using块。- 如果是
byte[]的GC压力太大,可以考虑直接用Marshal.AllocHGlobal申请非托管内存,手动管理释放,虽然要写释放代码,但性能好很多。
4.5 两台相机的帧同步问题
如果你的项目需求是两台的图像必须严格同步(比如3D重建、双目视觉),单纯靠软件时间戳是不够的,因为两台相机的曝光时刻无法保证一致。这种情况必须用硬件触发:相机A和B同时接收同一路外部触发信号,或者相机A的输出触发信号接到相机B的输入。海康SDK里设置TriggerSource为Line0或Line1,再配置TriggerActivation,配合外部接线,就能实现帧级同步。
如果只是“大概同时拍”的需求,两台相机用软件触发也行,但要在每次触发前做一次握手——即等待两台相机的取流线程都进入等待状态后再同时触发。这种软件同步的精度在毫秒级,能满足大多数不需要严格同步的场景。
5. 实操中的一些心得和细节补充
最后分享几个我在实际项目里总结的小经验,这些内容文档里一般不写,但对项目稳定运行帮助很大。
第一个是线程安全的UI更新问题。回调线程和工作线程都不能直接操作WinForm控件,否则会抛异常。我一般用BeginInvoke或者ConcurrentQueue把要显示的图像传给UI线程,UI线程定时器去队列里取。注意这里不要每帧都刷新UI,否则刷新太频繁界面会卡,我一般控制在15-20帧显示就足够了。
第二个是日志记录。产线上的上位机程序一定要有完整的日志系统,尤其是相机的开关、掉线、重连、参数修改这些关键事件。排查现场问题时,一个带时间戳的日志文件比什么都管用。我用的是NLog,配置了文件滚动,保留最近30天的日志,每天一个文件。
第三是部署时把MVS运行库一起打包。MVS软件安装后,运行时会依赖一些底层的视觉库和USB驱动,如果目标电脑没装MVS,光拷dll可能不够。最简单的做法是部署时把MVS安装包一起装一遍,或者在海康文档里找到“运行时依赖”的dll清单,手动打包过去。这里建议直接用MVS自带的“运行时部署”工具或者进行完整安装,省得出莫名其妙的dll缺失问题。
第四个细节是关于多线程帧队列的长度控制。如果工作线程处理慢,队列会越积越长,内存越来越大,而且图像延迟会越来越高,看起来像卡顿但不丢帧。我通常给队列设置上限,比如200帧,满了就丢最旧的帧,保证实时性。
总结下来,两台海康工业相机C#连接项目,难点不在SDK调用,而在资源管理、线程调度和异常兜底。只要把离线枚举、独立句柄、回调快速拷贝、停止释放顺序这几条主线理清楚,项目就能跑得很稳。以后再接4台、6台相机,方案也是一样往上叠加的,无非是设备和队列多几套。如果后面有时间,我打算再做一期关于海康相机与VisionPro联合开发的实践分享,到时候再细说图像处理那部分的东西。
本文还有配套的精品资源,点击获取