简介:面向C#工业视觉开发者的海康机器人相机SDK示例工程,重点解决回调取图与软触发取图两类常见采集需求,适合已具备WinForm与C#基础、希望快速接入海康工业相机的开发人员参考。压缩包共71个文件,约786KB,以15个cs源码为主体,配套14个dll运行库、resx/resources界面资源、json与xml配置及cache等构建中间文件,目录中可见相机接口、基础相机类、海康SDK封装与WinForm演示窗体,便于按模块理解采集流程。目前已有2638人学习下载,具备一定参考热度。读者可从中获得相机初始化、回调注册、软触发执行、图像数据接收与界面联动的可运行代码骨架,并借助ICamera、BaseCamera、HikCameraSDK等类梳理封装思路,减少从零对接SDK时的试错成本,适合作为工业视觉项目取图模块的起步模板。
1. 从一台“必须重启才能恢复”的上位机说起:C# 工业相机 SDK 到底扮演什么角色
去年帮一个做视觉检测的朋友看现场,工控机上跑着一个 C# 写的检测程序,相机是 GigE 接口的,白天跑得好好的,到了下午产线加速就必掉线,操作工的标准动作是关掉软件、重新打开——一天关十几次。我拆开看了一圈,问题根本不在检测算法,而在于调用相机 SDK 的那几十行代码:回调里直接更新了界面,句柄在异常分支里漏了关闭,Bayer 转 RGB 每帧还 new 了一个 Bitmap。这套 C# 工业相机 SDK 真实的价值,恰恰是给这类“能用但不可靠”的程序补上底层能力:它把厂商原生 DLL 里的枚举设备、打开句柄、配置曝光增益、触发取流、像素格式转换这一整条链路,包装成 .NET 里可以直接 new 出来、可以 Dispose 的对象。这篇笔记面向两类人:手上有一台工业相机、想在 C# 里把图稳稳取出来的人,和已经把图取出来、但想让这套代码撑过 8 小时连续跑的人。
2. 项目骨架与技术选型:托管封装层怎么压住原生 SDK 的复杂度
工业相机 SDK 的 C# 封装,本质上是一层很薄的“翻译层”。原生库是 C 接口,参数全是句柄、结构体指针和回调函数指针;上层业务是 C#,希望拿到的是CameraDevice对象和一个Bitmap或者byte[]。中间这层封装做得好不好,决定了后面是天天救火还是一次跑通。我一般把这块拆成三个关注点:P/Invoke 声明是否正确(结构体布局、字符集、调用约定),生命周期是否正确(句柄、回调委托、非托管内存三样东西谁负责释放),以及像素数据怎么从相机内存搬到托管堆而不多拷一次。
2.1 目录结构与依赖分布
一份可维护的封装,目录不会平铺成一堆.cs。按职责切成四层,改参数、换型号、写测试的时候才不会互相牵连。
| 路径 | 职责 | 改动频率 |
|---|---|---|
src/CamCtrl.Net/ | 原生 DLL 的 P/Invoke 声明、结构体定义、枚举常量 | 低,只有换 SDK 版本才动 |
src/CamCtrl.Core/ | 设备管理、取流控制、参数读写、像素转换 | 中,业务适配主要在这 |
src/CamCtrl.Demo/ | WinForms 预览程序,调参和验证用 | 高,但可以随便改 |
src/CamCtrl.Tests/ | 假设备实现下的单元测试 | 中 |
runtime/x64/ | 原生依赖 DLL、驱动安装包 | 基本不动 |
config/camera.json | 每台相机的参数模板(曝光、增益、ROI) | 现场按工位调 |
CamCtrl.Net里只有声明,没有逻辑,所有[DllImport]集中在一个NativeMethods静态类里,好处是换 SDK 版本时只改一个文件。CamCtrl.Core才是真正写业务的地方,它对上层暴露ICameraDevice接口,上层完全看不到句柄和指针。runtime/x64单独放原生 DLL 而不是丢进项目根目录,是因为部署时经常要区分调试版和现场版,混在一起最容易出现“我机器上能跑、现场机器上找不到 DLL”。
常见做法是把原生 DLL 的复制动作写进.csproj的None Include + CopyToOutputDirectory,而不是手工拷来拷去:
<ItemGroup> <!-- 原生依赖统一从 runtime 目录同步到输出目录,避免手工拷贝漏文件 --> <None Include="..\runtime\x64\*.dll"> <Link>x64\%(Filename)%(Extension)</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>这里有个细节容易被忽略:Link指定了子目录x64\,那么运行时 DLL 搜索路径就必须包含这个子目录,否则加载会失败。我一般在程序启动的第一行调用SetDllDirectory把x64目录加进去,比改PATH环境变量可控得多,也不会污染整机。
2.2 技术选型:为什么是 .NET + 原生 DLL + 结构体封送
相机厂商的原生库基本都是 C 接口,没有纯托管版本可用,所以 C# 侧只有 P/Invoke 一条路。选 .NET Framework 4.7.2 还是 .NET 6/8,我的判断标准很实际:现场工控机装的是 Windows 7 还是 Windows 10 以上、有没有现成的 Halcon/VisionPro 旧组件要共存。老产线升级成本高,4.7.2 兼容性最好;新项目直接 .NET 8,Span<byte>、NativeMemory、LibraryImport这些都能用上,像素处理那段代码能快不少。
平台目标必须是 x64,而且要在项目属性里固定下来,不能用 AnyCPU。原生相机库几乎清一色只提供 64 位版本,AnyCPU 在 64 位系统上会以 64 位进程启动没问题,但一旦被某个 32 位宿主进程加载,就会直接抛BadImageFormatException,而这个异常信息只说“试图加载格式不正确的程序”,根本不会告诉你是位数不匹配。
结构体封送是这层封装里最容易出事的地方。原生结构体通常是紧凑排列的,C# 默认的LayoutKind.Sequential会按字段自然对齐,遇到char[64]这种固定长度数组就容易错位,读出来的型号名是一串乱码。写法上要显式声明Pack = 1和CharSet.Ansi:
// 设备信息结构体:字段顺序和长度必须与原生头文件严格一一对应 [StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)] public struct DeviceInfoStruct { [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string ManufacturerName; // 厂商名,定长 64 字节 [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string ModelName; // 型号,定长 64 字节 [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 48)] public string SerialNumber; // 序列号 public ushort TLayerType; // 传输层:1=GigE,2=USB3,4=CameraLink public uint DeviceIndex; // 同型号多台时的索引 public byte Reserved0; public uint Reserved1; }Pack = 1意味着不做任何填充,字段一个字节挨一个字节排列。之所以这么敏感,是因为原生侧传过来的是一个裸内存块,C# 侧用Marshal.PtrToStructure一次性解释这段内存,只要总长度对不上,后面的字段全部错位。校验方法很简单:用Marshal.SizeOf(typeof(DeviceInfoStruct))打印出来,和原生头文件里对应的sizeof结果比对,两个数字必须一模一样,对不上就先别往下写。
2.3 环境准备与跑通第一张图
从零到看见第一张图,步骤不多但顺序不能乱。先把原生驱动装上,GigE 相机要确认网卡和相机在同一网段、网卡开了巨帧(Jumbo Frame,通常设 9014 字节),USB3 相机要确认供电和线材;然后把runtime/x64下的原生 DLL 和封装 DLL 一起拷到输出目录;最后打开 Demo 程序,先枚举,再打开,再开流。
最小可跑的一段代码长这样,我把句柄的生命周期收在一个using里,异常路径也能保证关闭:
// 最小取流示例:枚举 -> 打开 -> 开流 -> 收图 -> 逆序释放 public static void GrabOnce() { // 1. 初始化 SDK 全局环境,进程内只需调一次 int ret = NativeMethods.CAM_Init(); if (ret != 0) throw new InvalidOperationException($"SDK 初始化失败,错误码 {ret}"); var list = new DeviceInfoList(); NativeMethods.CAM_EnumDevices(ref list); if (list.Count == 0) throw new InvalidOperationException("未枚举到设备,先查网段与驱动"); IntPtr handle = IntPtr.Zero; try { // 2. 创建句柄 -> 按索引打开设备,两步不能调换顺序 NativeMethods.CAM_CreateHandle(ref handle); ret = NativeMethods.CAM_OpenDevice(handle, list.Items[0].DeviceIndex); if (ret != 0) throw new InvalidOperationException($"打开设备失败,错误码 {ret}"); // 3. 配参数必须在开流前完成,曝光单位是微秒 NativeMethods.CAM_SetEnumValue(handle, "TriggerMode", 0); // 0=连续采集 NativeMethods.CAM_SetFloatValue(handle, "ExposureTime", 5000f); // 5ms NativeMethods.CAM_SetEnumValue(handle, "PixelFormat", 0x01080001); // Mono8 NativeMethods.CAM_StartGrabbing(handle); // 4. 主动取一帧,超时 1000ms var frame = new FrameOutInfo(); ret = NativeMethods.CAM_GetOneFrameTimeout(handle, ref frame, 1000); if (ret == 0) SaveRaw(frame); } finally { // 5. 释放顺序:停流 -> 关设备 -> 销毁句柄,最后反初始化 NativeMethods.CAM_StopGrabbing(handle); NativeMethods.CAM_CloseDevice(handle); NativeMethods.CAM_DestroyHandle(handle); NativeMethods.CAM_Finalize(); } }这段代码里最值得记住的不是 API 名字,而是释放顺序:StopGrabbing必须在CloseDevice之前,CloseDevice必须在DestroyHandle之前。倒过来写,有些型号会直接卡在CloseDevice里不返回,现场表现就是“关软件的时候卡死,只能任务管理器杀进程”。曝光参数单位是微秒这件事也值得在代码里写注释——我见过有人按毫秒填5,结果相机以 5 微秒曝光,图像全黑,排查了一下午才发现是单位问题。
3. 核心链路拆解:从枚举设备到一帧图像落盘的完整调用
取图这条链路上,每一步都可能成为瓶颈,也都可能成为 bug 的藏身处。枚举阶段的问题多半是网络和驱动,参数配置阶段的问题多半是“这个节点在当前像素格式下不可写”,取流阶段的问题多半是线程和内存。把链路拆成四段分别验证,比一上来就写完整业务要快得多——每段都有独立的成功标志,出错时能立刻定位到是哪一段断的。
3.1 设备枚举与句柄生命周期
枚举这一步看着最简单,实际上是现场翻车最多的地方。GigE 相机的枚举依赖底层广播,跨网段、防火墙拦截、网卡绑定了多个 IP,都会导致“相机在厂商自带的客户端里能看到,在我的程序里看不到”。所以枚举结果不能只打印数量,要把型号、序列号、IP 一起打出来,出问题时日志本身就是证据。
// 枚举并把每台设备的关键信息落日志,出问题时可反查网段 public static IReadOnlyList<DeviceInfoStruct> Enumerate() { var list = new DeviceInfoList { Items = new DeviceInfoStruct[16] }; // 预分配 16 个槽位 int ret = NativeMethods.CAM_EnumDevices(ref list); if (ret != 0) { _log.Warn($"枚举返回错误码 {ret},按空列表处理"); return Array.Empty<DeviceInfoStruct>(); } var result = new List<DeviceInfoStruct>(list.Count); for (int i = 0; i < list.Count; i++) { var d = list.Items[i]; _log.Info($"设备[{i}] 型号={d.ModelName} 序列号={d.SerialNumber} " + $"传输层={d.TLayerType} 索引={d.DeviceIndex}"); result.Add(d); } return result; }DeviceInfoList里的Items数组要预先分配好长度,原生侧只负责往这块内存里填,不会帮你扩容。分配 16 个够用绝大多数场景,单机接十几台相机的产线我也见过,那就按实际数量调整,别省这点内存。DeviceIndex是后续打开设备的凭据,同一个索引在不同次枚举之间可能变化(尤其是 USB 设备插拔之后),所以生产代码里应该用序列号做业务标识,索引只作为本次会话内的定位手段。
句柄的生命周期建议用IDisposable包起来,把“创建-打开-关闭-销毁”四个动作绑在一个对象上,配合状态位防止重复释放:
// 句柄状态机:任何一步失败都要能安全回退,重复 Dispose 不抛异常 public sealed class CameraDevice : IDisposable { private IntPtr _handle = IntPtr.Zero; private bool _opened, _grabbing, _disposed; public void Open(uint index) { NativeMethods.CAM_CreateHandle(ref _handle); int ret = NativeMethods.CAM_OpenDevice(_handle, index); if (ret != 0) { NativeMethods.CAM_DestroyHandle(_handle); _handle = IntPtr.Zero; throw new CameraException("打开失败", ret); } _opened = true; } public void Dispose() { if (_disposed) return; _disposed = true; if (_grabbing) { NativeMethods.CAM_StopGrabbing(_handle); _grabbing = false; } if (_opened) { NativeMethods.CAM_CloseDevice(_handle); _opened = false; } if (_handle != IntPtr.Zero) { NativeMethods.CAM_DestroyHandle(_handle); _handle = IntPtr.Zero; } } }状态位_opened/_grabbing的意义在于:如果Open在中途失败,Dispose被finally调用时不会去CloseDevice一个根本没打开的句柄。这类“对无效句柄调用关闭”的操作,有些 SDK 返回错误码,有些直接崩溃,不能赌。
3.2 参数配置:曝光、增益、像素格式怎么设才生效
参数配置有两个大坑:一是节点名写错,二是节点在当前模式下不可写。工业相机的参数暴露遵循 GenICam 规范,每个参数是一个“节点”,节点有类型(整型、浮点、枚举、布尔、命令),也分“可读可写”和“只读”。比如某些型号在开流状态下不允许修改 ROI 宽度,你调SetIntValue会返回一个非零错误码,但如果不检查返回值,程序会以为设置成功了,实际用的还是老参数。
我习惯在设置之后立刻读回来比对:
// 设置后回读校验,节点不可写时能第一时间发现 public void SetExposure(double microseconds) { // 1. 先查上下限,超范围的值有的 SDK 会静默截断 NativeMethods.CAM_GetFloatValue(_handle, "ExposureTime", out float min, out float max, out _); double v = Math.Clamp(microseconds, min, max); int ret = NativeMethods.CAM_SetFloatValue(_handle, "ExposureTime", (float)v); if (ret != 0) throw new CameraException("曝光设置被拒绝,可能在开流状态下不可写", ret); // 2. 回读确认,误差超过 1us 视为未生效 NativeMethods.CAM_GetFloatValue(_handle, "ExposureTime", out _, out _, out float actual); if (Math.Abs(actual - v) > 1.0) _log.Warn($"曝光期望 {v}us,实际 {actual}us,检查是否被自动曝光覆盖"); }第二段回读的意义,在于防“自动曝光”这个隐性对手。相机默认可能开着ExposureAuto = Continuous,你手动设了 5000 微秒,下一帧又被自动算法改回去了,表现就是图像亮度忽明忽暗。所以设曝光之前先把ExposureAuto和GainAuto都设成Off,这一步别省。
GigE 相机还有一个和图像质量直接相关的参数:包大小GevSCPSPacketSize。网卡开了巨帧之后,这个值要同步调大(常见 8192 或 9000),否则大图传输时会被拆成大量小包,丢包率上来,画面出现横条纹。同时GevSCPD(包间隔)在交换机性能一般的时候要适当加大,用一点带宽换不丢包,通常划算。
像素格式决定了后面所有图像处理代码的写法:
| 像素格式 | 典型值 | 每像素字节 | 适用场景 |
|---|---|---|---|
| Mono8 | 0x01080001 | 1 | 黑白检测、尺寸测量,数据量最小 |
| Mono12 | 0x01100005 | 2 | 高动态范围场景,需要保留灰阶细节 |
| BayerRG8 | 0x01080009 | 1 | 彩色相机原始输出,需插值成 RGB |
| RGB8 | 0x02180014 | 3 | 直接可用,但带宽是 Mono8 的三倍 |
| YUV422 | 0x0210001F | 2 | 彩色预览,带宽与画质折中 |
选型逻辑很直接:能做单色检测就别上彩色,带宽和处理时间都省一半以上;必须彩色的场景,优先取 Bayer 原始数据在主机侧插值,而不是让相机内部转成 RGB8 再传——后者在千兆网下很容易把带宽跑满,帧率直接腰斩。
3.3 取流模式:回调取流和主动取流怎么选
SDK 一般提供两种取图方式。回调方式是注册一个函数,相机每来一帧就调用它一次,延迟低、不用自己轮询;主动方式是业务线程调GetOneFrameTimeout阻塞等待,拿到帧再返回。选择标准是节拍:产线固定节拍、每拍只要一张图,用主动取流,逻辑线性好调试;连续高速采集、要保证不丢帧,用回调取流,但必须把回调里的事情做到最少。
回调函数运行在 SDK 内部的采集线程上,不是 UI 线程,也不是你创建的任何线程。在这个函数里做图像处理、写日志、更新界面,都会拖慢整个采集循环,帧缓冲很快堆满,然后就是丢帧甚至断流。正确做法是在回调里只做一次内存拷贝,把数据塞进队列,立刻返回:
// 回调取流:回调内只拷贝,业务处理交给消费线程 private readonly BlockingCollection<FrameData> _queue = new(new ConcurrentQueue<FrameData>(), 8); private NativeMethods.ImageCallback _callback; // 必须用字段持有,防止被 GC 回收 public void StartGrabbing() { _callback = OnFrame; // 保存强引用 NativeMethods.CAM_RegisterImageCallBackEx(_handle, _callback, IntPtr.Zero); NativeMethods.CAM_StartGrabbing(_handle); _grabbing = true; } private void OnFrame(IntPtr pData, ref FrameOutInfo info, IntPtr user) { // 1. 只做一次非托管到托管的拷贝,长度用 info.FrameLen var buffer = new byte[info.FrameLen]; Marshal.Copy(pData, buffer, 0, info.FrameLen); var frame = new FrameData(buffer, info.Width, info.Height, info.PixelType, info.FrameNum, info.DevTimeStampHigh); // 2. 队列满说明下游处理不过来,丢最旧的,别阻塞采集线程 if (!_queue.TryAdd(frame)) _log.Warn($"队列已满,丢弃帧号 {info.FrameNum}"); }_callback这个字段是关键。CAM_RegisterImageCallBackEx只是把函数指针交给原生侧,原生侧不知道这个委托对象在托管堆上还需要被引用。如果写成CAM_RegisterImageCallBackEx(_handle, OnFrame, IntPtr.Zero),委托临时对象在方法返回后随时可能被 GC 回收,原生侧再调用就是一个野指针,程序在运行几分钟后毫无征兆地崩溃,日志里什么都没有——这就是典型的黑匣子问题。用字段持有引用,或者用GCHandle.Alloc固定,两种都行,前者更简单。
队列容量设 8 而不是无界,是刻意的。无界队列在消费端卡住时会一路吃内存,最后 OOM;有界队列满的时候丢帧并记日志,至少程序还活着,而且日志直接告诉你处理速度不够,问题定位得很干脆。
3.4 像素格式转换与落盘:少一次拷贝就是少一次卡顿
从相机拿到的原始数据是裸字节,要变成能显示、能送算法的格式,中间要做转换。Mono8 最简单,直接按行拷贝进Bitmap;Bayer 格式需要插值,可以用 SDK 自带的转换接口,也可以自己写双线性插值。我倾向用 SDK 的转换接口,它是原生实现的、有 SIMD 优化,比 C# 手写循环快好几倍,尤其是 500 万像素以上的图。
// Mono8 裸数据转 Bitmap:用 LockBits 直接写,避免逐像素 SetPixel public static Bitmap ToBitmap(byte[] raw, int width, int height) { var bmp = new Bitmap(width, height, PixelFormat.Format8bppIndexed); // 1. 8 位灰度必须配调色板,否则显示出来是伪彩 var palette = bmp.Palette; for (int i = 0; i < 256; i++) palette.Entries[i] = Color.FromArgb(i, i, i); bmp.Palette = palette; var data = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); try { // 2. stride 可能大于 width,逐行拷贝,不能一次性拷完 for (int y = 0; y < height; y++) { IntPtr dst = IntPtr.Add(data.Scan0, y * data.Stride); Marshal.Copy(raw, y * width, dst, width); } } finally { bmp.UnlockBits(data); } return bmp; }逐行拷贝这段是必须的:Bitmap每行的实际字节数(Stride)会按 4 字节对齐,宽度不是 4 的倍数时Stride > width,一次性Marshal.Copy整块数据会导致画面斜切。这个 bug 的典型症状是图像整体往一边歪,宽度 640、1280 这类 4 的倍数时又完全正常,很容易被误判成相机问题。
落盘策略上,我一般同时留两份:一份是处理后的 PNG/JPG 给人看,一份是原始 RAW 给复盘用。现场出问题时,算法同事最想要的是当时的原始数据,能拿回办公室反复调;只有处理后的图,很多问题复现不了。RAW 文件头写宽、高、像素格式、时间戳,读的时候按头解析,一个自定义格式二十行代码就够。
4. 触发与多相机同步:让软触发、硬触发和产线节拍对上
连续采集只适合调试,真上产线基本都是触发模式:工件到位,光电传感器给一个上升沿,相机曝光一帧。触发配错的表现很有迷惑性——图像能出来,就是位置偏移,或者同一个工件出了两张图,操作工看着像是“相机抽风”,实际上是触发极性、去抖时间或者触发延迟没调对。多相机场景还要多考虑一层:几台相机看同一个工件,时间差超过几毫秒,后面的尺寸合成就对不上。
4.1 软触发与硬触发的寄存器配置
硬触发是把相机的 I/O 线接到传感器上,靠电平变化触发;软触发是业务代码主动发一条命令让相机曝光。硬触发的实时性由硬件保证,抖动在微秒级;软触发的精度取决于操作系统调度,Windows 上抖动几毫秒很正常,不适合高速产线。
硬触发的配置顺序不能乱,必须先关流再改触发相关节点:
// 硬触发配置:Line0 接光电传感器,上升沿触发 public void SetupHardTrigger() { NativeMethods.CAM_StopGrabbing(_handle); // 1. 触发源选 Line0,触发模式打开 NativeMethods.CAM_SetEnumValue(_handle, "TriggerSource", 0); // 0=Line0 NativeMethods.CAM_SetEnumValue(_handle, "TriggerActivation", 0); // 0=上升沿 NativeMethods.CAM_SetEnumValue(_handle, "TriggerMode", 1); // 1=On // 2. 去抖:机械式光电开关抖动常见 100~500us,低于这个值会重复触发 NativeMethods.CAM_SetIntValue(_handle, "LineDebouncerTime", 200); // 3. 触发延迟,用于等工件走到视野中心,单位微秒 NativeMethods.CAM_SetFloatValue(_handle, "TriggerDelay", 3000f); NativeMethods.CAM_StartGrabbing(_handle); }LineDebouncerTime这个参数救过我好几次。普通光电开关在遮挡边缘会有几十到几百微秒的抖动,不设去抖,一个工件会触发两三次,日志里帧号跳得很快,操作工却说“就过一个件”。设成 200 微秒能滤掉绝大多数机械抖动,同时不会漏掉正常节拍。
软触发就简单得多,配好TriggerMode = On、TriggerSource = Software,每次需要图的时候发一条命令:
// 软触发:发命令后等图,超时要处理,不能无限等 public FrameData TriggerAndWait(int timeoutMs = 2000) { int ret = NativeMethods.CAM_TriggerSoftware(_handle); if (ret != 0) throw new CameraException("软触发命令发送失败", ret); var frame = new FrameOutInfo(); ret = NativeMethods.CAM_GetOneFrameTimeout(_handle, ref frame, timeoutMs); if (ret != 0) throw new CameraException($"等待图像超时 {timeoutMs}ms", ret); return FrameData.From(frame); }软触发的间隔要大于“曝光时间 + 读出时间 + 传输时间”之和。有人把触发间隔设成 10 毫秒去追 100fps,实际上单帧传输就要 30 毫秒,结果是触发命令堆积,相机内部缓冲区塞满,然后开始丢帧。算一下带宽心里就有数:1280×1024 的 Mono8 图是 1.3MB,千兆网理论 125MB/s,实际有效 80MB/s 左右,单帧传输下限就是 16 毫秒,再快也快不到哪去。
4.2 多相机并发的线程模型
四台相机同时采图的场景,最容易写出的代码是一个for循环里依次开流、依次取图。这样写每台相机都在等前一帧传完,总节拍被拉长四倍。正确模型是每台相机一个独立的采集上下文,各自开流,共用一组消费线程。
// 每台相机一个采集任务,共用消费队列,CancellationToken 统一退出 public async Task RunAsync(CancellationToken ct) { var consumers = Enumerable.Range(0, 2) .Select(_ => Task.Run(() => ConsumeLoop(ct), ct)).ToArray(); var grabbers = _cameras.Select(cam => Task.Run(() => { cam.StartGrabbing(); try { while (!ct.IsCancellationRequested) Thread.Sleep(200); } // 回调模式只需保活 finally { cam.StopGrabbing(); } }, ct)).ToArray(); await Task.WhenAll(grabbers); _queue.CompleteAdding(); await Task.WhenAll(consumers); }要点有三个。第一,开流放到独立任务里,一台相机开流慢不会拖住其他相机。第二,回调模式下业务线程不需要主动取图,只要保活,实际数据在回调里进队列。第三,消费线程数量按 CPU 核数配置,一般取物理核数的一半,太多反而因为上下文切换变慢。
带宽也要提前算。四台 500 万像素彩色相机、每台 10fps、Bayer8 格式,单台每秒 25MB,四台就是 100MB/s,已经接近千兆网上限。这种配置要么上万兆网卡,要么把帧率降到 5fps,要么换 USB3 接口分开走不同的控制器。见过把六台 GigE 相机全接在一个普通交换机上的方案,白天勉强跑,产线一加速就集体丢包——瓶颈根本不在程序。
4.3 时间戳与丢帧统计:把问题量化出来
工业相机的帧数据里通常带三个时间:设备时间戳(相机内部计时)、主机时间戳(SDK 收到帧的时刻)、帧号(相机累加计数)。这三个值组合起来,能判断出大部分链路问题。帧号不连续说明中间丢帧;设备时间戳和主机时间戳的差值持续增大,说明传输或主机处理跟不上。
// 帧连续性检查:丢帧数与主机-设备时间差同时监控 private uint _lastFrameNum = uint.MaxValue; private void CheckFrame(FrameOutInfo info) { if (_lastFrameNum != uint.MaxValue && info.FrameNum != _lastFrameNum + 1) { uint lost = info.FrameNum - _lastFrameNum - 1; _log.Warn($"帧号跳变 {_lastFrameNum} -> {info.FrameNum},丢失 {lost} 帧"); Interlocked.Add(ref _lostFrames, lost); } _lastFrameNum = info.FrameNum; // 主机侧延迟,持续偏大说明消费端处理不过来 long hostMs = info.HostTimeStamp / 1000; long devMs = ((long)info.DevTimeStampHigh << 32 | info.DevTimeStampLow) / 1000; if (Math.Abs(hostMs - devMs) > 500) _log.Warn($"主机与设备时间差 {hostMs - devMs}ms,检查网络与处理耗时"); }这些统计值我一般做成一行状态栏常驻显示:已采集帧数、丢帧数、平均耗时、队列深度。现场出问题的时候,看一眼状态栏就能判断是相机没出图、还是出了图但没接住、还是接住了但处理慢。没有这几个数字,排查全靠猜,效率差得不是一点半点。
5. 避坑与排查:这 5 个坑我在现场都踩过
相机相关的故障,报错信息普遍很少,一个错误码对应十几种可能,光看日志基本推不出来。下面这几条是我反复遇到、并且已经形成固定处理动作的,按“现象 → 原因 → 解决”记下来,下次遇到能省几个小时。
5.1 枚举不到设备:先怀疑网段,再怀疑驱动
现象:厂商自带的客户端能看到相机,自己写的 C# 程序调CAM_EnumDevices返回 0 台设备,或者偶尔能枚举到、重启后又没有了。
原因:GigE 相机靠广播发现,主机网卡和相机不在同一网段时,标准广播到不了;Windows 防火墙对 UDP 广播的拦截也会造成时有时无;网卡如果配了多个 IP,绑定顺序不对也会漏。还有一种情况是网卡开了“节能以太网”,空闲时降速,导致广播包丢失。
解决:先把主机网卡 IP 和相机 IP 配到同一网段,掩码一致;关闭防火墙对该程序的拦截;在设备管理器里把网卡的节能选项全部关掉;程序启动时打印所有网卡的 IP 和掩码,枚举为空时把这块信息一起写进日志。这套动作走完,九成枚举问题能定位。
5.2 回调里更新界面:界面越卡,丢帧越多
现象:预览窗口一开就卡,图像有明显延迟,跑一会儿开始丢帧;把预览窗口最小化,丢帧就消失了。
原因:回调函数里直接调Invoke更新 PictureBox,UI 线程忙的时候Invoke会阻塞,回调线程被卡住,SDK 的帧缓冲迅速堆满,然后丢帧甚至断流。这是典型的“看起来是相机问题,实际是 UI 线程问题”。
解决:回调只做内存拷贝入队,界面刷新由一个独立的定时器按固定频率(比如 30ms)从队列取最新一帧渲染,中间的帧直接丢弃。预览只是给人看的,不需要每一帧都上屏。渲染时复用同一个Bitmap对象,用完Dispose旧的,别每帧 new。
5.3 运行几分钟后无征兆崩溃:委托被回收了
现象:程序启动正常,取流也正常,跑三到十分钟后进程直接消失,Windows 事件日志里只有一条0xc0000005访问冲突,没有任何托管异常堆栈。
原因:注册回调时传的是方法组,编译器生成的委托对象没有被任何字段引用,GC 一次回收之后原生侧持有的就是野指针,下一帧回调写内存就崩。这类崩溃最坑的地方在于时间不确定,短则几十秒,长则几小时,而且和业务逻辑完全无关。
解决:把委托存成类的字段,或者用GCHandle.Alloc(callback)固定住,在停止取流后再Free。另外可以在调试版里给回调加一个计数器,定期打印回调次数,如果数字长时间不增长但又没报错,也要怀疑回调链路断了。
5.4 第二次打开报设备被占用:异常路径漏了关闭
现象:第一次运行正常,关闭软件后重新打开,提示设备被占用或打开失败,必须拔插网线或者重启电脑才能恢复。
原因:某次运行中途抛了异常,CloseDevice没被执行,原生侧认为这个句柄还活着。有些 SDK 的句柄是进程级独占的,进程退出时才释放,所以表现成“重启电脑就好了”。这个坑在调试阶段特别频繁,因为调试器里中断程序根本不会走finally。
解决:所有打开动作必须包在try/finally里,或者用IDisposable加using;在Dispose里用状态位防止对无效句柄操作;调试时尽量用“停止调试”而不是直接关窗口,让finally有机会执行。另外可以在程序启动时先尝试枚举并打印设备状态,占用情况能提前看出来。
5.5 部署到现场机器就报 BadImageFormatException
现象:开发机上跑得好好的,拷到现场工控机上启动即崩,异常信息是“试图加载格式不正确的程序”,指向原生 DLL。
原因:三种可能——项目是 AnyCPU 而现场以 32 位方式启动;原生 DLL 是 64 位但拷了 32 位版本;原生 DLL 依赖的 VC++ 运行库在现场机器上没装。异常信息本身完全不说这些,只说是格式问题。
解决:项目平台目标固定 x64,并在生成后事件里校验输出目录的 DLL 位数;把 VC++ 运行库打进安装包一起部署;在程序入口处捕获BadImageFormatException和DllNotFoundException,把当前进程位数、DLL 路径、依赖缺失情况一起写进日志。部署清单里加一条“目标机器位数与运行库版本”,能挡掉一大半返工。
6. 二次开发改造:把 SDK 包成能测、能重连的取图服务
直接调 SDK 的代码写到业务里,最直接的后果是没法测——没有相机就跑不起来,单元测试形同虚设。我在第二个项目里做了一次改造,把相机操作抽成接口,业务只依赖接口,SDK 实现和假实现各来一份。这个改动花了大半天,后面每次调参数、加相机、复现问题都省下来了。
// 业务只依赖这个接口,测试时换成 FakeCameraSource 即可 public interface ICameraSource : IDisposable { bool IsOpen { get; } Task OpenAsync(CancellationToken ct); Task<FrameData> GrabAsync(int timeoutMs, CancellationToken ct); event Action<CameraState> StateChanged; // 断线、重连、参数变更都从这里出 }StateChanged这个事件是改造的核心。相机断线在产线上是常态——网线松了、交换机重启、供电波动——业务层不需要知道底层发生了什么,只要收到“掉线”就能把当前工件标记为待复检,收到“恢复”就继续跑。重连逻辑放在实现类里,用指数退避,第一次 200 毫秒后重试,之后翻倍,上限 5 秒,无限重试直到取消。
| 改造点 | 具体做法 | 收益 |
|---|---|---|
| 接口化 | 抽出ICameraSource,业务不引用原生类型 | 无相机也能跑单测和 CI |
| 假实现 | FakeCameraSource按固定节拍吐预置图 | 算法调试不占产线设备 |
| 状态机 | Closed/Opening/Streaming/Reconnecting四态 | 断线可自愈,不用重启软件 |
| 配置化 | 曝光、增益、触发参数放 JSON | 换工位只改配置不改代码 |
| 录制回放 | RAW 文件带头信息,可回放 | 现场问题能拿回来复现 |
验证方法我固定走三步。第一步,无相机跑单测,覆盖打开失败、取图超时、断线重连三条路径,FakeCameraSource里可以主动抛异常来模拟。第二步,接真相机跑录制回放,把一段 500 帧的 RAW 序列灌进去,确认处理耗时和内存都平。第三步,压力跑,连续 8 小时不停流,每半小时打印一次句柄数、GC 堆大小、丢帧计数,曲线平稳才算过。这三步不复杂,但能把 90% 的现场问题挡在交付之前。
踩过最疼的一次是没做第三步:交付后第三天客户反馈“跑一夜就卡死”,日志里什么都没有。后来加了句柄计数才发现,每次重连都新建了一个句柄但旧的没销毁,一夜重连两百多次,句柄泄漏把进程拖垮了。从那以后我每次写完取流模块,不管多赶,都强制走一遍 8 小时压力跑,就看句柄数和内存两条线是不是平的。希望这些经验能帮你少走一遍弯路。
本文还有配套的精品资源,点击获取