简介:面向C#工业视觉开发者的海康机器人工业相机SDK调用示例,聚焦回调取图与软触发取图两类常用采集方式,适合需要把相机接入WinForms上位机、完成图像采集与业务联调的初中级开发者参考。压缩包共71个文件,约786KB,以cs源码、dll引用库、resx资源、json与cache构建文件为主,包含相机接口抽象、海康相机封装、配置类及示例窗体等模块,便于按项目结构理解SDK接入方式。已有2638人学习,说明其在工业相机二次开发场景中具有一定参考价值。读者可从中获得回调与软触发两种取图流程的可运行代码骨架、相机参数配置与界面集成思路,并据此排查采集回调、触发模式切换和依赖引用等问题,减少从零搭建采集模块的时间。
1. C# 工业相机 SDK 的真实位置:从“能连上”到“稳定出图”差在哪
某产线视觉工位用 C# 写上位机,相机能枚举、能预览,看起来一切正常;连续跑两个小时开始偶发丢帧,操作员点一次“停止”再“开始”又恢复,过一阵 UI 卡死,换一台工控机后曝光参数全变。问题不在算法,而在 C# 工业相机 SDK 的调用链:句柄什么时候开、回调在哪个线程、缓冲区给多大、参数按什么顺序下发,任何一环没对齐,现场就会用“玄学”两个字概括。这份资源围绕 C# 工业相机 SDK 的工程落地展开,适合做检测、测量、读码、定位的上位机开发者,重点解决从“能连上”到“稳定出图”之间的工程细节。
2. 拆开 SDK 的调用链:C# 侧要抓住的五个对象
2.1 设备枚举与句柄生命周期
工业相机 SDK 在 C# 里的第一层不是“取图”,而是设备句柄。枚举、打开、关闭、释放这四个动作必须成对出现,常见翻车是打开失败后直接抛异常,后面的Close和Dispose没走到,下一次再打开就报“设备被占用”。我一般把打开和释放包在try/finally里,枚举只用来拿序列号,真正打开时用序列号而不是索引,因为索引会随 USB 插拔顺序变化。
using IndustrialCamera.Sdk; var manager = new DeviceManager(); var devices = manager.EnumerateDevices(); // 只枚举,不打开 if (devices.Count == 0) { throw new InvalidOperationException("未发现相机,先检查供电和驱动"); } var target = devices.First(d => d.SerialNumber == "SN-XXXX"); CameraDevice camera = null; try { camera = manager.Open(target.SerialNumber); // 用序列号打开,避免索引漂移 camera.SetPixelFormat(PixelFormat.Mono8); camera.StartGrabbing(); } finally { camera?.StopGrabbing(); camera?.Dispose(); // 释放句柄,避免下一次打开被占用 manager.Dispose(); }这里的关键参数是SerialNumber和PixelFormat。序列号是设备的稳定标识,索引只适合单相机演示。PixelFormat在打开后、取流前设置,部分 SDK 允许运行中切换,但切换瞬间会清空缓冲区,现场尽量一次定好。Dispose不是可有可无的收尾,它负责释放底层采集线程和缓冲区,少调一次,进程退出时可能留下僵尸句柄。
2.2 取流模式:回调、轮询与缓冲区
取流一般有三种方式:回调、轮询、异步等待。回调吞吐高,适合多相机和高速线;轮询写起来简单,但延迟和 CPU 占用不好控;异步等待介于两者之间。C# 里最容易踩的坑是把回调当成 UI 线程用,回调线程里直接改控件属性,程序不一定立刻崩,但会在某个时间点抛跨线程异常。常见做法是回调里只做拷贝和入队,UI 线程从队列取图。
var queue = new ConcurrentQueue<FrameData>(); camera.FrameReceived += (sender, frame) => { // 回调线程只做轻量拷贝,不碰 UI,不做文件 IO var copy = frame.Clone(); queue.Enqueue(copy); if (queue.Count > 8) // 消费跟不上时丢弃旧帧,防止内存膨胀 { queue.TryDequeue(out var old); old?.Dispose(); } }; camera.StartGrabbing();queue.Count > 8是保护阈值,不是越大越好。缓冲区数量要跟消费速度匹配:算法每帧 30 ms,相机 30 fps,队列留 3 到 5 帧就够;留 50 帧只会让延迟滚雪球。回调里做保存图片、写日志、刷新界面,都会拖慢回调线程,缓冲区一满,SDK 要么丢帧,要么直接报超时。血泪经验是:回调只负责“接住”,不负责“处理”。
2.3 像素格式与图像转换的边界
工业相机常见输出有 Mono8、Mono12、BayerRG8、RGB24。C# 的Bitmap不是万能容器,Mono8 可以直接按 8 位灰度构造,Mono12 要决定是压到 8 位还是保留 16 位,Bayer 格式需要先做去马赛克。转换时最容易错的是stride:相机给的每行字节数不一定等于宽度,尤其宽度不是 4 的倍数时,按width * 1去拷会花屏。
public static Bitmap Mono8ToBitmap(FrameData frame) { int width = frame.Width; int height = frame.Height; int stride = frame.Stride; // 以 SDK 回读为准,不要硬编码 width var bmp = new Bitmap(width, height, PixelFormat.Format8bppIndexed); var rect = new Rectangle(0, 0, width, height); var data = bmp.LockBits(rect, ImageLockMode.WriteOnly, bmp.PixelFormat); for (int y = 0; y < height; y++) { IntPtr src = IntPtr.Add(frame.DataPtr, y * stride); IntPtr dst = IntPtr.Add(data.Scan0, y * data.Stride); Buffer.MemoryCopy((void*)src, (void*)dst, data.Stride, width); } bmp.UnlockBits(data); return bmp; }这段代码只处理 Mono8,frame.Stride必须从 SDK 回读,不能假设等于宽度。LockBits之后一定要UnlockBits,否则 Bitmap 会一直锁住内存,后面Dispose也释放不干净。如果相机输出 Bayer,不要在这层硬转 RGB,先确认 SDK 是否支持内置转换;自己写去马赛克容易把颜色通道顺序搞反,调试时看起来像“相机偏色”,其实是转换代码把 R 和 B 换了。
2.4 参数配置顺序:先固定再调曝光
参数下发顺序不对,表现是“设置成功了但图像没变化”。常见顺序是:先停流,再设触发模式、ROI、像素格式,最后设曝光和增益,设完回读确认,再开流。曝光和增益不是独立旋钮:曝光拉长能提亮,但运动物体会拖影;增益补亮会引入噪声。现场调参我一般先锁增益,再调曝光满足运动模糊,最后才用增益补剩下的亮度。
| 参数 | 常见设置 | 注意点 |
|---|---|---|
| 曝光时间 | 500~8000 微秒 | 单位因 SDK 而异,以回读值为准 |
| 增益 | 0~12 dB | 先低后高,超过 12 dB 噪声明显 |
| 触发模式 | 软触发或硬触发 | 自由运行只适合调试 |
| 像素格式 | Mono8 优先 | 带宽不够时先降位深 |
| 超时 | 1000~3000 ms | 太小会误报,太大掩盖问题 |
camera.StopGrabbing(); camera.SetTriggerMode(TriggerMode.Software); camera.SetRoi(0, 0, 1280, 1024); camera.SetPixelFormat(PixelFormat.Mono8); camera.SetExposureTime(3000); // 微秒,具体单位看 SDK 文档 camera.SetGain(3.0); var actualExposure = camera.GetExposureTime(); // 回读,确认没被相机裁剪 camera.StartGrabbing();回读这一步不要省。有些相机在触发模式下限制最大曝光,设 8000 微秒实际只生效 4000,不回读就会误判为“曝光不准”。ROI 也要在停流时改,运行中改 ROI 可能触发缓冲区重分配,轻则丢几帧,重则采集线程直接退出。
3. 从零跑通第一张图:C# 工程配置与最小采集闭环
3.1 工程目标平台与 DLL 放置
C# 工程最常见的启动失败不是代码写错,而是位数不匹配。SDK 运行库是 64 位,工程用 x86 编译,或者反过来,表现是“找不到 DLL”或“BadImageFormatException”。目标平台不要长期停在 AnyCPU,现场工控机统一 x64 就固定 x64。SDK 的托管 DLL 和原生运行库要放在输出目录,原生 DLL 一般不能只靠引用托管 DLL 解决。
# 输出目录结构示意,原生运行库要和 exe 同目录或在其子目录 bin/x64/Debug/ ├── App.exe ├── IndustrialCamera.Sdk.Managed.dll ├── IndustrialCamera.Sdk.Native.dll └── runtime/ └── win-x64/ └── native/ └── IndustrialCamera.Sdk.Native.dll托管 DLL 用“添加引用”或 NuGet 方式引入,原生 DLL 复制到输出目录。不要手动改PATH指向开发机目录,换一台机器就失效。发布时把runtime目录一起带走,缺一个原生依赖,程序会在DeviceManager构造时直接抛异常,日志里只写“初始化失败”,排查成本很高。
3.2 枚举、打开、配置、取流的最小代码
最小闭环不需要界面,控制台程序就能验证。流程是:初始化 SDK、枚举、按序列号打开、设像素格式、注册回调、开流、等若干帧、停流、释放。每一步都检查返回码,不要用异常兜底所有错误,SDK 的错误码里包含“设备离线”“缓冲区满”“参数越界”,比异常信息具体。
using IndustrialCamera.Sdk; using var manager = new DeviceManager(); manager.Initialize(); var devices = manager.EnumerateDevices(); var info = devices.FirstOrDefault(); if (info == null) throw new Exception("no camera"); using var camera = manager.Open(info.SerialNumber); camera.SetPixelFormat(PixelFormat.Mono8); camera.SetExposureTime(3000); camera.SetGain(2.0); int received = 0; camera.FrameReceived += (s, frame) => { Interlocked.Increment(ref received); if (received == 1) { var bmp = Mono8ToBitmap(frame); bmp.Save("first_frame.png"); bmp.Dispose(); } }; camera.StartGrabbing(); Thread.Sleep(2000); camera.StopGrabbing(); Console.WriteLine($"received={received}");Initialize只调一次,重复调可能返回“已初始化”。Open之后设置参数,StartGrabbing之前注册回调,顺序反了会漏掉前几帧。Thread.Sleep(2000)只是演示,实际工程用ManualResetEventSlim等第一帧到达再继续。收到的帧数明显低于预期,先看曝光时间和帧率上限,再看回调里有没有做耗时操作。
3.3 保存与释放:别让句柄悬空
保存图像本身不难,难的是保存时不阻塞采集。直接在回调里Bitmap.Save会写磁盘,磁盘一慢,回调线程被拖住,缓冲区很快耗尽。我一般把帧拷贝进队列,单独一个保存线程消费,文件名带时间戳和帧号,方便对丢帧。保存格式优先 PNG 或 BMP,JPEG 有压缩耗时,调试阶段不划算。
var saveDir = Path.Combine(AppContext.BaseDirectory, "capture"); Directory.CreateDirectory(saveDir); long frameNo = 0; while (queue.TryDequeue(out var frame)) { using (frame) using (var bmp = Mono8ToBitmap(frame)) { var name = $"{DateTime.Now:yyyyMMdd_HHmmss_fff}_{frameNo++}.png"; bmp.Save(Path.Combine(saveDir, name), ImageFormat.Png); } }using (frame)保证每帧的 native 内存释放,using (var bmp)保证 GDI 句柄释放。保存线程退出时要先把队列排空再释放相机,否则最后几帧还在队列里,相机句柄已经关了,访问帧内存会崩。文件名里的帧号不要用回调里的局部变量,用Interlocked.Increment的全局计数,否则多线程下会重名。
3.4 用日志把失败点钉死
采集程序出问题,最怕日志只写“采集失败”。SDK 返回码、当前帧号、最后一次成功时间、相机序列号、曝光值,这些上下文才能定位。日志不要每个回调都写,按秒汇总或状态变化时写,否则日志 IO 反过来拖慢采集。我一般用滚动文件,单文件 20 MB,保留 5 个,够现场回溯。
void LogState(string tag, long frameNo, int errorCode) { var line = $"{DateTime.Now:O} tag={tag} sn={info.SerialNumber} " + $"frame={frameNo} err={errorCode} exp={camera.GetExposureTime()}"; File.AppendAllText("camera.log", line + Environment.NewLine); }errorCode不要转成“未知错误”就完事,保留原始码,查 SDK 文档时能对上。exp回读值能判断参数有没有被相机裁剪。日志里不要写图像二进制,只写元数据,否则文件膨胀很快。
4. 参数怎么设才不飘:曝光、增益、触发与超时的组合
4.1 曝光与增益的联动边界
曝光和增益都影响亮度,但代价不同。曝光拉长,进光量增加,运动物体拖影;增益放大信号,暗部提亮但噪声同步放大。现场调参的顺序我固定为:先定帧率上限,再定曝光上限,再调增益,最后看直方图。帧率要求 30 fps,曝光就不能超过 1/30 秒减去读出时间,硬设 50000 微秒,帧率一定掉。
| 场景 | 曝光策略 | 增益策略 | 风险 |
|---|---|---|---|
| 高速运动 | 尽量短,配合频闪 | 低增益,补光优先 | 拖影、欠曝 |
| 静态测量 | 可放宽 | 0~6 dB | 过曝丢细节 |
| 低照度 | 受帧率限制 | 不超过 12 dB | 噪声大、误判 |
| 多相机同步 | 统一曝光 | 统一增益 | 亮度不一致 |
camera.SetExposureTime(2500); camera.SetGain(4.0); var exp = camera.GetExposureTime(); var gain = camera.GetGain(); if (Math.Abs(exp - 2500) > 100) { LogState("exposure_clipped", 0, 0); }回读差超过 100 微秒就记一条日志,说明相机内部按帧率或触发限制裁剪了曝光。增益单位有的是 dB,有的是倍数,不要跨品牌套公式,以 SDK 回读为准。曝光和增益都设完再开流,运行中反复改参数会让自动曝光算法和手动值打架,图像亮度会“呼吸”。
4.2 软触发与硬触发的选型
软触发由软件发命令,布线简单,但触发时机受操作系统调度影响,抖动大;硬触发由光电传感器或 PLC 给电平,实时性好,适合运动中的工件。自由运行适合调试和静态场景,不适合节拍固定的产线。选型不是越硬越好,硬触发要配触发沿、去抖、延时,接错线一样丢帧。
| 触发方式 | 延迟 | 抖动 | 适用场景 |
|---|---|---|---|
| 软触发 | 毫秒级 | 较大 | 静态工位、调试 |
| 硬触发 | 微秒级 | 小 | 运动工件、多相机同步 |
| 自由运行 | 固定周期 | 中 | 预览、录像 |
camera.SetTriggerMode(TriggerMode.Hardware); camera.SetTriggerSource(TriggerSource.Line1); camera.SetTriggerActivation(TriggerActivation.RisingEdge); camera.SetTriggerDelay(0); // 微秒,按传感器到工位的距离折算 camera.StartGrabbing();TriggerSource要跟实际接线一致,Line1 接成 Line2 就是“一帧都没有”。TriggerDelay不是玄学,按传送带速度和传感器到相机视野中心的距离算,先算再试。硬触发模式下不要在回调里再发软触发,两种触发源同时开,帧号会乱。
4.3 超时、重传与丢帧计数
超时设置是采集线程的“后悔药”。设得太小,网络抖动或磁盘忙时误报超时;设得太大,相机掉线后线程卡死,界面像死机。我一般设 1000~3000 ms,并且在超时后做一次“停流、清缓冲区、重开流”,不要在原线程里无限重试。丢帧统计看帧号缺口,比看“感觉卡了”可靠。
long lastFrameNo = -1; long gap = 0; camera.FrameReceived += (s, frame) => { if (lastFrameNo >= 0 && frame.FrameNo != lastFrameNo + 1) { Interlocked.Add(ref gap, frame.FrameNo - lastFrameNo - 1); } lastFrameNo = frame.FrameNo; queue.Enqueue(frame.Clone()); };FrameNo是否连续取决于 SDK,有的相机在触发模式下帧号按触发计数,有的按传输计数,先确认语义再算缺口。gap持续增长说明链路有问题,不是算法慢。重开流之前要StopGrabbing并释放旧缓冲区,直接StartGrabbing可能返回“已在采集”。
4.4 多相机场景的带宽与命名
多相机同时开流,问题往往不是 SDK 不支持,而是带宽和 USB 控制器分配。四台相机接同一个 USB 控制器,每台都跑全分辨率,超时是必然的。常见做法是分控制器、降分辨率、降帧率、改像素格式。相机命名不要用Camera0、Camera1,用序列号后四位绑定工位,换线后不会张冠李戴。
var cameras = new[] { manager.Open("SN-A001"), manager.Open("SN-A002") }; foreach (var cam in cameras) { cam.SetPixelFormat(PixelFormat.Mono8); cam.SetRoi(0, 0, 1280, 1024); cam.StartGrabbing(); }打开多相机时逐台初始化,一台失败不影响其他台,但要把失败序列号记进日志。ROI 越小带宽越低,先降 ROI 再降帧率,图像质量损失比降位深小。多相机时间戳不要用DateTime.Now代替,主机时间受系统对时影响,用 SDK 时间戳做同步,再用主机时间做展示。
5. 避坑与排查:五个让采集线程停不下来的现场问题
5.1 现象:启动报“找不到 DLL”,换开发机又正常
现象是程序在开发机跑得好,拷到工控机启动就报DllNotFoundException或BadImageFormatException。原因通常有两个:原生 DLL 没跟着输出目录走,或者 C# 工程目标平台和 SDK 运行库位数不一致。解决时先看异常里的 DLL 名,再去输出目录确认文件存在;用dumpbin /headers看原生 DLL 是 x64 还是 x86,工程平台改成一致。发布时把原生运行库目录整体复制,不要只复制托管 DLL。改完平台后清一次bin和obj,否则旧产物还会被加载。
5.2 现象:回调里更新 UI 直接崩,或者偶发卡死
现象是预览正常,点“开始”后界面偶尔卡住,日志里出现跨线程访问控件。原因是FrameReceived回调运行在 SDK 的采集线程,不是 UI 线程,直接给PictureBox.Image赋值违反 WinForms/WPF 线程规则。解决是把回调里的帧放进ConcurrentQueue,用Control.BeginInvoke或 WPF 的Dispatcher.BeginInvoke在 UI 线程取最新一帧,并且只取一帧,不要每帧都排队刷新。刷新频率高于界面刷新率没有意义,反而堆积消息。
5.3 现象:采几十帧后内存持续涨,最后采集线程退出
现象是任务管理器里内存曲线一直向上,采几百帧后程序变慢或直接停流。原因多是帧对象没释放,或者回调里克隆了帧但消费队列丢弃时没Dispose。解决是给FrameData加引用计数或确保Clone和Dispose成对;队列满时丢弃旧帧要调用旧帧的Dispose;保存线程退出前排空队列。用GC.GetTotalMemory只能看托管内存,native 内存要用任务管理器或 SDK 自带统计看。每次改完采集逻辑,跑十分钟压测再上线。
5.4 现象:触发模式下一帧都没有,自由运行却正常
现象是切到硬触发后回调不进来,切回自由运行立刻出图。原因通常是触发源选错、触发沿不对、传感器没给到电平,或者相机在等待触发时超时设置太短直接报错。解决顺序是:先确认TriggerSource和接线一致,再用软件强制触发一次看有没有帧,然后用示波器或万用表看传感器输出。触发延迟设得过大也会表现为“没有帧”,因为帧在等待延迟。触发模式下不要同时开自动曝光,第一帧还没来,自动曝光没有输入。
5.5 现象:多相机同时开流后频繁超时,单台正常
现象是单台相机跑满帧率没问题,两台一起开就频繁超时,日志里错误码指向缓冲区或传输超时。原因是共享带宽或 USB 控制器过载,也可能是两台相机用了同一个 IP 段或同一根 USB 总线。解决是先降分辨率或帧率验证带宽,再把相机分到不同控制器/网卡,确认每台相机的序列号和 IP 绑定正确。多相机不要用索引打开,索引在枚举顺序变化后会串台。超时后不要只重试当前相机,先停掉所有流,清一次缓冲区再逐台恢复。
6. 把采集做成可验证的:三个指标与一个回放习惯
6.1 用时间戳和帧计数算真实帧率
帧率不要看 SDK 界面上的标称值,用回调时间戳算。每收到一帧记一次Stopwatch时间,统计最近 100 帧的间隔,得到实际回调帧率;同时用帧号差算相机输出帧率。两者差得多,说明回调或队列在丢帧。下面的代码只做统计,不参与业务逻辑,放在独立线程里跑。
var sw = Stopwatch.StartNew(); var stamps = new Queue<double>(); camera.FrameReceived += (s, frame) => { var t = sw.Elapsed.TotalSeconds; stamps.Enqueue(t); if (stamps.Count > 100) stamps.Dequeue(); }; // 计算:100 帧跨度 / 帧数 double fps = stamps.Count > 1 ? (stamps.Last() - stamps.First()) / (stamps.Count - 1) : 0;stamps只留最近 100 个,避免长时间运行内存膨胀。算出来的 fps 要和相机设置的帧率、曝光时间对得上;曝光 3000 微秒理论最高不到 333 fps,设 500 fps 没有意义。
6.2 用丢帧率判断链路瓶颈
丢帧率比“感觉卡”可靠。用相机帧号缺口除以理论帧数,得到链路丢帧率;用入队帧数除以回调帧数,得到消费丢帧率。前者高说明传输或触发有问题,后者高说明算法或保存线程太慢。现场我一般把链路丢帧率控制在 0.1% 以内,消费丢帧率单独看,不混在一起。
| 指标 | 计算方式 | 异常时先看 |
|---|---|---|
| 回调帧率 | 最近 100 帧时间差 | 曝光、触发、带宽 |
| 链路丢帧率 | 帧号缺口 / 理论帧数 | 网线、USB 控制器、超时 |
| 消费丢帧率 | 队列丢弃数 / 入队数 | 算法耗时、磁盘 IO |
6.3 保存原始帧做回放验证
只存 PNG 截图不够,算法复现需要原始像素。调试阶段我会把前 200 帧按raw格式存下来,连同宽度、高度、stride、像素格式写进一个json描述文件,后面不用接相机也能回放。回放时按同样的转换路径走一遍,能区分“相机问题”和“算法问题”。从那以后我每次接新相机,都先跑最小闭环、算一遍丢帧率、存一段原始帧,再谈检测逻辑。希望帮到你。
本文还有配套的精品资源,点击获取