简介面向工业图像采集与机器视觉开发者的《大恒CG300采集卡C#Demo》示例工程聚焦大恒CG300采集卡的C#二次开发适合已有C#基础、需要打通硬件驱动与上位机软件的中级开发者。工程演示板卡初始化、四路信号采集与场采集帧的实时界面显示并保留了晶振改为28MHz后恢复正常工作的排错线索有助于理解时钟配置与采样率的匹配关系。压缩包共28个文件、约78KB以cs源码为主体配合sln解决方案、csproj工程与resx资源、exe及pdb调试产物可直接用Visual Studio打开编译运行少量txt与settings文件补充了配置说明。目前已有492人学习下载。读者可获得一套可运行的多通道采集与帧显示骨架代码参考其多线程分工避免通道间干扰借助表单设计器与图表控件完成数据可视化并据此搭建自己的采集系统。1. CG300 采集卡 C# Demo能跑起来却改不动的真正原因第一次拿到 CG300 采集卡 C# Demo 的人多半会经历同一个落差双击 exe画面出来了帧率也稳心里一松等把这段代码搬进自己的上位机改两行就崩关窗口还偶尔卡死日志里只有一句干巴巴的超时。这类官方示例的定位是证明硬件、驱动、线缆三者是通的不是给你一个能直接上产线的取图模块。它把打开设备、开流、回调、转换、显示全塞进一个窗体看着省事实际每层都缺边界处理缓冲没归还、线程没收敛、异常没兜住。这篇笔记按「看懂骨架、跑通闭环、调对参数、记录翻车」的顺序拆一遍适合手里已经有这块卡、正在用 C# 做机器视觉上位机的工程师也适合刚接手一个「能跑但不敢动」的 Demo、想把它改成自家模块的人。2. 拆开 Demo 的骨架设备、流、回调三层各自管什么先别急着改代码把工程目录摊开看一眼比什么都值。CG300 这类 CoaXPress 采集卡的示例工程无论厂商包装成什么样落到 C# 侧几乎都是同一个套路一个窗体负责交互一个封装类持有设备句柄一个转换工具类处理像素外加一份配置。真正决定你能不能改动的是中间那层封装类有没有把生命周期管住。2.1 一份典型 Demo 里的四类文件文件/模块承担职责改动建议MainForm.cs按钮事件、PictureBox 刷新、状态栏只改界面别往里塞业务逻辑CameraWrapper.cs打开/关闭设备、开流、注册回调核心必须逐行读懂ImageConvert.csBayer 转 RGB、Mono 转 Bitmap视需求替换性能敏感app.config / 参数 ini相机 SN、曝光、ROI 默认值上线前必改别用 Demo 默认值绝大多数人踩的第一个坑是把MainForm.cs当成主战场直接在按钮回调里写采集逻辑。窗体一关Dispose顺序错乱设备句柄没释放下次启动就报「设备被占用」。正确的切法是窗体只做参数输入和图像展示采集全部收进封装类封装类对外只暴露Open、Start、Stop、Close四个方法。2.2 一次取图在内部走了哪几步Galaxy SDK 的 C# 封装本质上是对 C 接口的一层薄壳理解 C 侧的对象树C# 侧就不会迷路。一次完整的取图链路是这样的IGXFactory.GetInstance().Init()初始化运行时进程内只该调一次UpdateAllDeviceList枚举在线设备拿到IGXDeviceInfo列表OpenDeviceBySN打开指定设备得到IGXDevice这一步默认是独占访问GetRemoteFeatureControl拿到远程特征控制器所有相机参数都从这里下发GetStream(1)拿到流通道Open()之后开始接收数据RegisterCaptureCallback注册回调采集卡每来一帧就在 SDK 的工作线程里回调一次回调里取数据、拷贝、归还缓冲区停止时依次Close流、Close设备、Uninit工厂。第 7 步是整个链路里最容易被忽略的一环。SDK 给回调的帧数据本质上是向采集卡「借」出来的一块缓冲回调返回前必须QueueBuffer还回去否则缓冲池几帧之内就被借空表现就是「跑三四秒画面卡住再也不动」。这个问题在 Demo 里不明显因为 Demo 的帧率低、缓冲池大等你把帧率拉到满速几秒钟就现原形。我一般会在封装类里加一个计数器记录「已回调帧数」和「已归还帧数」两者差值持续增长就是没还。2.3 用最小代码确认设备能被枚举到在动任何采集逻辑之前先写一个二十行的控制台程序只做枚举。这一步能一次性排除掉驱动、线缆、供电、SDK 位数四类问题省下后面大量试错时间。// 最小枚举程序确认 SDK、驱动、链路三者都通 using System; using System.Collections.Generic; using GalaxySDK.NET; // 命名空间以本机安装的 SDK 版本为准 class EnumDemo { static void Main() { // 1) 初始化进程内只调一次和 Uninit 必须配对 IGXFactory.GetInstance().Init(); var devList new ListIGXDeviceInfo(); // 2) 2000ms 是枚举超时多网卡机器上广播会慢别设太小 IGXFactory.GetInstance().UpdateAllDeviceList(2000, devList); Console.WriteLine($在线设备数: {devList.Count}); foreach (var info in devList) { // 3) SN 才是唯一标识别用 IP 或列表下标 Console.WriteLine(${info.GetModelName()} | SN{info.GetSN()} | $接口{info.GetDeviceClass()}); } IGXFactory.GetInstance().Uninit(); } }逻辑说明Init加载底层运行时和 GenICam 的传输层这一步失败通常意味着环境变量或 DLL 路径有问题而不是硬件问题。UpdateAllDeviceList走的是设备发现协议超时值给 2000ms 是经验值产线上设备多的时候可以加到 3000ms。参数说明第二个入参是超时毫秒数返回值通过List带出不要用Count 0就直接判定「卡坏了」先看设备管理器里采集卡有没有正常枚举。枚举数为 0 的时候按这个顺序查设备管理器里采集卡是否带黄色感叹号驱动没装对→ 线缆是否插在采集卡对应的通道上CXP 是多通道插错口不报错但枚举不到→ 相机是否供电CXP 支持 PoCXP但功率不够时相机会反复重启→ 进程位数是否和 SDK 一致32 位进程加载不了 64 位 DLL报的却是「找不到设备」。这四步走完九成的「枚举不到」都能定位。3. 跑通最小闭环SDK 环境、位深匹配与回调注册枚举通了只是拿到了入场券真正跑出第一帧图像中间还隔着环境、格式、线程三道坎。这一章的目标很明确让你在自己的工程里从零跑出一帧能存成 PNG 的图而不是只能看 Demo 的窗体。3.1 安装之后的三个位置要盯住采集卡的运行环境不止「装个驱动」这么简单它至少有四块东西需要就位第一块是驱动。CoaXPress 采集卡在设备管理器里通常出现在「图像设备」或「系统设备」下名字带厂商前缀。驱动装错版本的典型症状是设备能枚举到但一开流就返回ACCESS_DENIED或NOT_INITIALIZED。第二块是 SDK 的原生库。C# 封装层底下是一堆 C DLL它们必须和你的输出目录在同一级或者被写进PATH。很多人改了平台目标从 AnyCPU 切到 x64忘了把x64子目录下的原生库拷过去运行时抛DllNotFoundException报的却是 C# 方法名看着像托管代码的问题。第三块是 GenICam 运行时。采集卡 SDK 依赖 GenICam 的传输层安装时会写GENICAM_ROOT_V3_x之类的环境变量。这台机器如果装过别的相机厂商的 SDK两边会抢同一个变量症状非常玄学今天好用明天不好用重启一次又好了。第四块是 VC 运行库。原生库依赖特定版本的 MSVC 运行时缺了会在加载阶段直接崩连异常都抓不到。我的习惯是把这四块写进一份部署清单每台产线机器按清单核对一遍而不是靠「拷过去能跑就行」。血泪经验是开发机上什么都装了所以永远不复现到了产线机才炸而那时候你不在现场。3.2 打开设备、开流、注册回调的完整一段下面这段是封装类的核心把打开、配置、开流、回调、停止串成一条线。参数顺序有讲究后面 4.4 会专门讲为什么。using System; using System.Collections.Concurrent; using System.Runtime.InteropServices; using GalaxySDK.NET; public sealed class CameraGrabber { private IGXDevice _device; private IGXStream _stream; private IGXFeatureControl _fc; // 有界队列满了就丢帧绝不阻塞回调线程 private readonly BlockingCollectionbyte[] _queue new BlockingCollectionbyte[](8); public int Width { get; private set; } public int Height { get; private set; } public void Open(string sn) { IGXFactory.GetInstance().Init(); // 1) 独占访问避免被 Demo 或另一进程同时打开 _device IGXFactory.GetInstance() .OpenDeviceBySN(sn, GXAccessMode.GX_ACCESS_EXCLUSIVE); _fc _device.GetRemoteFeatureControl(); // 2) 先像素格式再 ROI最后触发——顺序反了会报 OUT_OF_RANGE _fc.GetEnumFeature(PixelFormat).SetValue(Mono8); _fc.GetIntFeature(Width).SetValue(1920); _fc.GetIntFeature(Height).SetValue(1080); Width (int)_fc.GetIntFeature(Width).GetValue(); Height (int)_fc.GetIntFeature(Height).GetValue(); // 3) 调试阶段先自由运行触发配置留到 4.1 _fc.GetEnumFeature(TriggerMode).SetValue(Off); // 4) 开流并注册回调 _stream _device.GetStream(1); _stream.Open(); _stream.RegisterCaptureCallback(new FrameSink(this)); } public void Close() { // 顺序不能反先停流再关设备最后 Uninit _stream?.Close(); _stream null; _device?.Close(); _device null; _queue.CompleteAdding(); IGXFactory.GetInstance().Uninit(); } // 回调实现放在内部类避免把采集线程和 UI 搅在一起 private sealed class FrameSink : IGXCaptureCallback { private readonly CameraGrabber _owner; public FrameSink(CameraGrabber owner) _owner owner; public void OnFrameCallback(IGXDevice device, IGXStream stream, IFrameData frame) { try { if (frame.GetStatus() ! GX_FRAME_STATUS_SUCCESS) return; int size (int)frame.GetImageSize(); var buf new byte[size]; // 具体取图接口以 SDK 版本为准有的返回 IntPtr有的直接给 byte[] Marshal.Copy(frame.GetImage(), buf, 0, size); // TryAdd 不阻塞队列满直接丢弃宁可丢帧不可卡死回调 _owner._queue.TryAdd(buf); } finally { // 5) 必须归还缓冲否则几秒后彻底停帧 stream.QueueBuffer(frame); } } } }逻辑说明Open里把PixelFormat放在最前面是因为大部分相机的 ROI 取值上限依赖当前像素格式的有效宽度顺序反了会抛参数越界。Close里的顺序同样关键先关流再关设备反过来会让流通道持有已销毁的设备引用表现为关闭时卡住三到五秒。参数说明GX_ACCESS_EXCLUSIVE是独占模式如果要做双进程冗余采集可以换GX_ACCESS_READONLY但读模式下不能下发参数。队列长度 8 是我常用的起始值按「下游处理一帧耗时 × 帧率」估比如下游存图 30ms、帧率 30fps队列至少留 2 到 3 帧余量。3.3 Mono8、BayerRG8 与 RGB24 的转换代价像素格式的选择直接决定 CPU 占用。同一块卡、同一个分辨率格式选错能让单核跑满还掉帧。像素格式每像素字节1080p 单帧大小转换代价适用场景Mono81约 2.0 MB无直接可显示黑白检测、尺寸测量BayerRG81约 2.0 MB需去马赛克CPU 大户彩色但带宽紧张RGB83约 6.0 MB无但带宽三倍彩色且带宽充裕关键判断只有一句话如果采集卡本身支持在硬件侧做 Bayer 转 RGB就把转换交给卡别在 C# 里手写双线性插值。手写版本在 1080p30fps 下很容易吃掉一整个核而且边缘会出现拉链纹。切换方式就是把PixelFormat特征设成RGB8代价是带宽涨三倍这时候要先算 4.3 的带宽账别转换没做先把链路撑爆。3.4 显示与落盘不要在回调线程里碰 UI回调运行在 SDK 的工作线程上任何同步的 UI 调用Invoke不加BeginInvoke、直接赋值控件属性都可能让回调阻塞进而拖垮整个采集链路。正确做法是回调只做拷贝入队另起一个消费线程处理显示和存盘。// 消费线程定期从队列取图负责显示和落盘 private void Pump() { while (!_queue.IsCompleted) { if (!_queue.TryTake(out var data, 20)) continue; // 1) 8bpp 灰度图必须设调色板否则显示成彩色乱码 var bmp new Bitmap(Width, Height, PixelFormat.Format8bppIndexed); var pal bmp.Palette; for (int i 0; i 256; i) pal.Entries[i] Color.FromArgb(i, i, i); bmp.Palette pal; // 2) 用 LockBits Marshal.Copy别逐像素 SetPixel var rect new Rectangle(0, 0, Width, Height); var bmpData bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); Marshal.Copy(data, 0, bmpData.Scan0, data.Length); bmp.UnlockBits(bmpData); // 3) 这里再切回 UI 线程异步刷新 uiContext.Post(_ pictureBox.Image bmp, null); } }逻辑说明8bpp 索引位图的调色板默认是灰度调色板没问题但如果你拷贝的是 24 位数据却建了 8bpp 位图画面会是花屏这是新手最容易怀疑「相机坏了」的场景。LockBits加一次整块拷贝比SetPixel快两个数量级。参数说明TryTake的 20ms 超时是为了让线程能及时响应退出信号别写成无限等待否则关窗时线程收不回来。落盘如果走的是同一条消费线程注意存图 IO 抖动会反过来挤队列稳妥点就再拆一层显示和存盘各一个消费者。4. 参数怎么落地触发源、曝光顺序与带宽换算Demo 里的参数面板通常把所有特征一股脑列出来能调是能调但你不知道先调哪个、调到什么范围。这一章把产线上最常用的几组参数拆开讲包括它们之间的依赖关系。4.1 软触发、硬触发与自由运行怎么选模式触发源帧节奏典型用途自由运行无由相机帧率决定调试、录像、非同步场景软触发TriggerSoftware命令由上位机决定单次拍照、人工放料硬触发Line0 / 编码器由外部信号决定产线连续检测、飞拍产线检测十有八九用硬触发因为被测物是动的只有位置信号到了才该曝光。软触发适合「放一个料拍一张」的工位但要注意从上位机发命令到实际曝光有 1 到 5ms 的延迟运动场景下这点延迟就是几个像素的位移。// 硬触发Line0 上升沿去抖 10us _fc.GetEnumFeature(TriggerMode).SetValue(On); _fc.GetEnumFeature(TriggerSource).SetValue(Line0); _fc.GetEnumFeature(TriggerActivation).SetValue(RisingEdge); _fc.GetFloatFeature(TriggerDebouncerTime).SetValue(10.0); // 需要软件补拍时切回软触发只需两行 // _fc.GetEnumFeature(TriggerSource).SetValue(Software); // _fc.GetCommandFeature(TriggerSoftware).Execute();逻辑说明TriggerMode必须先在 Off 状态配置TriggerSource再切 On部分机型反过来会抛「触发源不可更改」。TriggerDebouncerTime单位是微秒光电开关抖动大的现场给到 20 到 50 都正常给太大会吞掉真实的窄脉冲。参数说明TriggerActivation的取值还有FallingEdge和LevelHigh编码器场景用LevelHigh配合分频更稳。软触发的TriggerSoftware是命令型特征只能Execute不能SetValue写错会抛类型异常。4.2 曝光先于增益的调整顺序曝光时间和增益都能提亮画面但代价完全不同曝光拉长带来运动模糊增益拉高带来噪点。调整顺序永远是先曝光、后增益、最后才考虑打光。经验取值曝光时间不超过帧周期的四分之一。30fps 的帧周期是 33.3ms那曝光就压在 8ms 以内被测物速度越快这个上限越紧。增益一般从 0dB 起步黑白检测类应用最多给到 6 到 9dB再往上噪点会明显吃掉边缘检测的稳定性。// 曝光单位通常是微秒先用当前值读一次再改避免超范围 var exp _fc.GetFloatFeature(ExposureTime); double min exp.GetMin(), max exp.GetMax(); exp.SetValue(Math.Min(Math.Max(8000, min), max)); // 目标 8ms _fc.GetFloatFeature(Gain).SetValue(0.0);逻辑说明所有浮点特征都应该先读GetMin/GetMax再写直接给一个大值在部分机型上不会报错而是静默截断到边界你以为是 20ms 实际是 2ms然后花半天怀疑代码。参数说明曝光单位在不同机型上可能是微秒也可能是行周期切换ExposureAuto为Off之后读一次当前值最保险。4.3 带宽怎么算通道数、位深与帧率的关系CG300 这类采集卡走的是多通道高速串行链路带宽是第一约束。算法很简单单帧字节数 宽 × 高 × 每像素字节 所需带宽bit/s 单帧字节数 × 8 × 帧率1920×1080×1 字节 × 8 × 60fps ≈ 995 Mbps接近 1 Gbps。换成 RGB8 就是 2.99 Gbps。链路是 x4 通道的话总带宽看着很宽裕但单通道的速率上限才是瓶颈而且实际有效载荷通常只有物理速率的七成左右。分辨率格式帧率所需带宽备注1920×1080Mono830约 0.50 Gbps轻松1920×1080Mono860约 1.00 Gbps单通道要看余量1920×1080RGB860约 2.99 Gbps建议多通道或降帧2448×2048Mono840约 1.60 Gbps常见 500 万像素配置带宽超限的表现不是报错而是不定时丢帧日志里只有零星几次GX_FRAME_STATUS_INCOMPLETE。这时候降的不是帧率就是位深别去调曝光那不是一回事。4.4 参数下发顺序里的隐性依赖产线上最常见的「昨天还好今天不行」一半来自参数下发顺序。几个硬规则与流相关的参数Width、Height、PixelFormat、OffsetX/Y必须在停流状态下改开流时改会失败或花屏ROI 的OffsetX要满足对齐要求通常和像素格式绑定的粒度相关改成奇数宽度就报错触发相关参数在开流前配好开流后再改触发源部分机型需要重新开流才生效所有GetMin/GetMax读到的范围都依赖当前像素格式和 ROI改了 ROI 就要重新读一次。我一般会在封装类里加一个ApplyProfile方法把所有参数按固定顺序写死在一个列表里顺序是停流 → 像素格式 → ROI → 触发 → 曝光增益 → 开流。这样即使换了机型只要改列表内容不动流程。5. 避坑清单C# 调采集卡最容易翻车的五类下面五条全部来自实际项目里出现过的现场问题按现象、原因、解决三段写碰到类似的直接对号入座。5.1 崩溃与资源类三个高频现象现象一程序关闭时卡住 5 到 10 秒或者干脆不退出。原因Close顺序写反先关了设备再去关流流通道在等待一个已经销毁的设备响应或者回调线程还在跑Uninit时运行时在等线程收敛。 解决严格按「停回调 → 关流 → 关设备 →Uninit」的顺序并且在停回调时加一个ManualResetEvent等回调真正退出最多等 2 秒超时也强制往下走别无限等。现象二第二次启动报「设备被占用」。原因上一次进程异常退出句柄没释放或者 Demo 和你的程序同时开着独占访问互斥。 解决开发阶段在Open外面包一层重试最多试三次、每次间隔 500ms。上线前把任务管理器里残留的进程名查一遍产线机上经常有人手动双击过 Demo。现象三内存持续上涨跑一晚上涨到几个 G。原因回调里new byte[]每帧分配一次大对象堆碎片化或者Bitmap用完没DisposeGDI 句柄泄漏。 解决帧缓冲用池化复用按最大帧大小预分配几个数组循环使用Bitmap每次替换后把旧的Dispose这个 GDI 泄漏在任务管理器里看不出来要看 GDI 对象计数。5.2 丢帧与超时类两个带宽相关现象现象四画面看起来正常但每隔几十秒卡一下GetStatus偶尔返回不完整帧。原因带宽临界。链路速率刚好卡在需求附近任何一次总线争用都会让某一帧传不完。也可能是队列满导致的主动丢弃。 解决先把PixelFormat降到 Mono8 或者把帧率降 20%看现象是否消失这是最快的判定方法。确认为带宽问题后要么减 ROI要么分通道。现象五开流成功但一帧都不来回调一次都没进。原因触发模式是 On 但触发源没接信号相机在等一个永远不来的沿或者线缆插在采集卡的另一路通道上流通道号对不上。 解决先把TriggerMode设 Off 验证自由运行能不能出图能出就是触发配置问题不能出就是通道号或线缆问题。这一条二分法能省掉一半排查时间。6. 把 Demo 改成产线模块缓冲池、断线重连与一个自检习惯Demo 到产线之间差的往往不是功能而是「异常发生时能不能自己爬起来」。两个改动性价比最高。6.1 缓冲池与断线重连缓冲池的做法很朴素启动时按最大分辨率预分配 N 个byte[]回调里从空闲栈里取消费完再还回去取不到就丢帧。这样整晚运行也不会有一次大对象分配。// 极简缓冲池取不到就丢帧绝不等待 private readonly ConcurrentBagbyte[] _pool new ConcurrentBagbyte[](); private byte[] Rent(int size) { if (_pool.TryTake(out var buf) buf.Length size) return buf; return null; // 池空直接丢帧保证回调不阻塞 } private void Return(byte[] buf) _pool.Add(buf);断线重连我一般用一个独立的心跳线程每 2 秒枚举一次在线 SN 列表发现目标 SN 消失就标记状态为断线关闭流和设备然后每 5 秒重试打开最多重试 60 次。关键在于重连期间不要重建整个窗体只重建采集对象UI 上给一个明显的断线标识。6.2 上线前必跑的一张自检表检查项判据不通过的典型表现枚举稳定性连续 10 次枚举结果一致偶发枚举不到多为 GenICam 环境冲突帧连续性5 分钟内GetStatus全部成功零星不完整帧带宽或线缆问题关闭耗时Close在 2 秒内返回卡住顺序或线程收敛问题内存与 GDI1 小时内存曲线平稳锯齿上涨泄漏异常恢复拔线 30 秒再插回能自愈卡在断线态不恢复参数回读写入值与回读值一致静默截断范围没判这张表我每次上线前都会跑一遍比看代码管用。有一回就是靠「参数回读」这一项抓到一个问题增益写入 12回读却是 9原因是当前像素格式下增益上限被压低了代码没判范围静默截断现场花了两天才定位到。从那以后我每次改采集参数都强制走一遍「写—回读—比对」读回来的值和写进去的对不上就先怀疑范围再怀疑代码。这套习惯看着啰嗦但它把「玄学掉帧」这类问题挡在了上线之前。希望帮到你。本文还有配套的精品资源点击获取