资讯动态

CG300采集卡C# Demo:Camera Link工业相机采集与避坑

发布时间:2026/10/24 6:48:55 来源:尧图企业网站定制
简介大恒CG300采集卡的C#示例工程面向从事工业控制、图像处理与科学数据采集的开发者及初学者用于演示在.NET环境下完成设备初始化、多通道数据采集与界面帧显示的完整流程。压缩包共28个文件、约78KB以cs源码、exe可执行程序、resx资源与csproj/sln工程文件为主另含少量txt说明和配置缓存文件可直接用Visual Studio打开对照调试。示例围绕四路采集与场采集帧显示展开涉及多线程采集、数据可视化控件以及晶振频率与驱动参数匹配等排错点描述中特别提示将晶振改为28M可恢复正常工作便于读者理解硬件时钟配置对采样同步的影响。目前已有492人学习下载适合希望快速搭建CG300采集卡C#开发框架、复用工程结构并积累驱动调试经验的技术人员参考。1. 产线重装系统之后相机不出图了产线上那台工控机重装系统之后相机就再也不出图了——这是我接手 CG300 采集卡 C# Demo 时遇到的第一个场面。这份 Demo 做的事情很朴素用 C# 把一张 Camera Link 采集卡调通让相机的图像流进一个 WinForms 窗口能开流、能停流、能存单帧也能看到实时帧率。它适合两类人一类是手上已经有 CG300 这一级别采集卡、被厂商原生 SDK 里那堆 C 示例劝退的上位机开发者另一类是要把工业相机接进现有检测软件、需要一段能直接抄的采集骨架的集成方。往下读你会看到环境怎么对齐、回调线程的边界在哪、花屏和内存上涨该往哪一层查最后再谈怎么把这份 Demo 改造成能进产线的采集模块。2. 工程骨架CG300 采集卡 C# Demo 的目录与技术栈2.1 技术栈盘点.NET Framework、WinForms 与非托管 SDK 的三层关系这份 Demo 的技术栈没有任何花哨的地方它是典型的「工控上位机三件套」.NET Framework 4.x WinForms 厂商托管 SDK 封装。很多人第一眼会觉得太老但工业现场的约束摆在那里——工控机常年不联网系统镜像被 IT 部门锁死只保证 .NET Framework 的运行库存在.NET 6/8 的自包含发布虽然能跑但会多出几十兆的运行时目录遇到杀软和系统还原点的时候反而麻烦。真正需要理解的是三层调用关系它决定了后面 90% 的报错该往哪查层级载体典型文件出问题时的表现原生层C/C 动态库采集卡驱动配套的运行时 dll找不到入口点、访问冲突、蓝屏级异常托管封装层C# 类库多数只给 dll命名空间前缀通常带厂商标识编译能过、运行时抛「无法加载 DLL」应用层WinForms 主程序窗体、显示控件、采集封装类界面卡死、花屏、内存缓慢上涨原生层负责和采集卡驱动通信托管封装层用 P/Invoke 把一组句柄式 API 包成 C# 类应用层再去调用。翻车最多的位置是第二层和第三层之间——你以为自己在调一个普通 C# 方法实际上它背后是一次跨托管边界的调用参数里的IntPtr和回调委托都不能按普通对象的生命周期去理解。我在文中统一用DevSdk代指厂商托管封装的命名空间实际以你手上的 dll 与示例为准类名和枚举名会有出入但调用顺序和参数语义大同小异。先把这一点记住后面所有代码都是围绕「顺序」和「语义」讲的不是让你逐字复制。2.2 目录结构把设备控制和界面拆开的那几层拿到 Demo 之后不要急着点运行先花十分钟看目录。一个能长期维护的采集 Demo目录一定是把「跟硬件打交道的部分」和「跟人打交道的部分」分开的。下面这个结构是我在实际项目里会用的分法也是这份 Demo 骨架的合理形态CG300Demo/ ├── CG300Demo.sln ├── src/ │ ├── CG300Demo.UI/ # WinForms 主程序只管界面 │ │ ├── Forms/MainForm.cs # 开流、停流、存图按钮的事件 │ │ ├── Controls/ImageCanvas.cs # 自绘显示控件不用 PictureBox │ │ └── Program.cs │ ├── CG300Demo.Grab/ # 采集逻辑不引用任何 UI 类型 │ │ ├── Grabber.cs # 打开设备、配参数、注册回调 │ │ ├── FrameBuffer.cs # 帧缓冲池负责复用与归还 │ │ └── GrabberEvents.cs # 帧到达、丢帧、异常三类事件 │ └── CG300Demo.Interop/ # 对厂商托管 SDK 的二次包装 │ ├── DeviceApi.cs # 设备枚举与打开 │ └── NativeMethods.cs # 需要自己补的 P/Invoke 声明 ├── lib/ │ ├── x86/ # 原生 dll 托管封装 dll │ └── x64/ ├── config/camera.json # 像素格式、触发源、缓冲帧数 └── tools/FrameDump/ # 离线回放已保存的原始帧关键在于CG300Demo.Grab这个项目不引用System.Windows.Forms。这条约束看起来只是一句口头规范实际价值非常大它逼着你在采集层只暴露事件和数据而不是「回调里直接改 label 的 Text」。等你要把这份 Demo 接到 WPF 界面、接到控制台服务、接到一个跑在后台的检测线程时采集层可以原封不动搬过去。tools/FrameDump这个小工具也值得单独说。它把存下来的 raw 帧纯粹的像素字节不带文件头在离屏状态下重新读出来跑一遍转换逻辑完全不依赖硬件。调试图像格式的时候有它在你就不用每次都求着别人把相机通电。我一般会在项目第一天就把它写出来十几行代码省掉后面无数次「现场不方便调试」的扯皮。2.3 环境准备驱动、运行库与位数对齐环境这一步没什么智力含量但它是血泪经验最密集的地方。按顺序走关机断电把采集卡插进 PCIe 插槽。Camera Link 采集卡基本都不支持热插拔通电插卡是给自己找麻烦。装采集卡驱动。装完在设备管理器里确认没有黄色感叹号再重启一次让驱动把板载的固件配置完整加载。装厂商 SDK 的运行时。注意区分「开发包」和「运行时」——有的版本开发包里带的 dll 和运行时的 dll 不是同一份。编译前先定死平台目标别用 AnyCPU。最后一条是重点。厂商的原生 dll 如果是 32 位的你用 AnyCPU 编译出来在 64 位系统上默认跑成 64 位进程调用时会抛BadImageFormatException如果你在项目属性里勾了「首选 32 位」在部分 .NET Framework 版本下又会出现「明明勾了还是报错」的玄学。我的做法是直接把配置管理器里的平台目标改成明确的x86或x64跟lib下哪个目录的 dll 对应就选哪个。# 跑之前先确认三件事比在代码里加十行 try-catch 有用 # 1. 进程位数与 dll 位数是否一致在任务管理器里看进程有没有带 32 位标记 # 2. 原生 dll 是否在输出目录的根下而不是在 lib/x86 子目录里 # 3. 厂商的示例程序是否已经退出采集卡同一时刻只允许一个进程独占这段不是让脚本干什么而是把现场排查的顺序固化下来。第二点特别容易忽略很多人只把托管封装的 dll 复制到输出目录忘了原生 dll 必须和 exe 同级或者必须加到系统 PATH 里结果就是一运行就DllNotFoundException异常信息里写的还是托管 dll 的名字指向性很差。环境准备的验收标准只有一个厂商自带的那个最简示例能不能出图。它出不了图你的 C# 代码写得再对也没用先回去查驱动和线缆。Camera Link 的线缆接头有螺丝没拧到底就是间歇性丢帧这个坑我踩过不止一次。3. 核心链路从打开设备到回调里拿到第一帧3.1 初始化与枚举先搞清楚卡上到底有几个通道采集链路的起点不是打开设备而是进程级的系统初始化。这一步几乎所有采集卡 SDK 都有特点是在整个进程生命周期里只该调用一次且必须和反初始化配对。// 进程级初始化放在 Program.Main 或采集模块的静态构造里只调一次 DevSdk.System.Initialize(); try { // 枚举当前机器上可用的采集设备数量 int devCount DevSdk.System.EnumDeviceCount(); if (devCount 0) { // 到这一步基本可以断定是驱动没装好或者卡没插稳 throw new InvalidOperationException(未枚举到采集设备请检查驱动与插槽); } // 打开 0 号设备返回一个设备句柄对象 var device DevSdk.System.OpenDevice(0); // 一张 Camera Link 卡上可能有多个通道Base 配置通常只用通道 0 int chCount device.GetChannelCount(); Console.WriteLine($设备数{devCount}当前设备通道数{chCount}); } catch (Exception ex) { // 初始化失败必须回滚否则下次调用 Initialize 会返回已初始化 DevSdk.System.Uninitialize(); throw; }逻辑上有两个容易被忽略的点。EnumDeviceCount返回 0 是一个强信号它几乎排除了业务代码的问题直接指向驱动层而OpenDevice返回的句柄对象不要在方法里用局部变量随手丢掉很多封装层的析构函数里会调CloseDevice一旦被 GC 回收后面的采集就全断了。至于Initialize和Uninitialize的配对异常路径上也要写。有些 SDK 的Initialize是引用计数的有些是布尔标记前者重复调用无害后者第二次会直接抛异常。不确定的情况下就当成布尔标记处理用 try/finally 包住。还有一个现场高频问题枚举不到设备但驱动明明装好了。八成是厂商的示例程序还开着或者上一次调试的进程在任务管理器里没退干净。采集卡是独占资源不是共享内存这一点跟网口相机完全不同。3.2 创建采集通道与配置参数像素格式、触发源、缓冲帧数通道配置是整个链路里参数最多的一步也是「设备打开了但不出图」的主要发生地。下面这段是连续采集模式下的典型配置顺序// 打开通道ChannelType 对应 Camera Link 的 Base / Medium / Full 配置 var channel device.OpenChannel(0, DevSdk.ChannelType.Base); // 像素格式必须和相机实际输出一致配错了就是花屏或者全黑 channel.SetPixelFormat(DevSdk.PixelFormat.Mono8); // 采集模式连续采集 or 软触发/硬触发 channel.SetAcquisitionMode(DevSdk.AcquisitionMode.Continuous); // 触发源。自由运行模式下设成 None 或 Internal由卡自己发节拍 channel.SetTriggerSource(DevSdk.TriggerSource.None); // 缓冲帧数SDK 内部环形队列的深度决定能扛住多久的处理抖动 channel.SetBufferCount(8); // 单帧等待超时单位毫秒。太小会频繁报超时太大会让停流变慢 channel.SetTimeout(2000);把这几个参数摊开看参数常用取值配错的后果像素格式Mono8 / Mono12 / BayerRG8全黑、条纹、颜色错乱采集模式Continuous / Trigger触发模式下不发触发就一帧不出触发源None / Software / Line0该自由跑的被卡住等信号缓冲帧数4 ~ 16太小丢帧太大吃内存且增加延迟超时1000 ~ 3000 ms太小误报太大停流要等半天像素格式是最需要跟相机对上的。相机内部输出 Mono8你这边配 Mono12SDK 会按每像素两字节去解析结果就是图像被拉成两倍宽、灰度乱七八糟。反过来相机出 Bayer 格式你配成 Mono8 能出图但全是灰度马赛克得再做一次去马赛克才正常。这一条对不上后面所有调试都是白费。缓冲帧数值得单独说一句。它不是越大越好。SDK 的环形队列每格都是一整帧的内存2K 分辨率 8 位灰度一帧就是 4MB 左右16 帧就是 64MB。但这个值太小的话UI 线程只要稍微卡一下比如弹出个对话框、加载个配置队列立刻被写满新帧无处可放SDK 就会丢帧或者直接报溢出。我一般的起点是 8然后看丢帧统计再调整。超时参数影响的是「停流」这个动作的体感。超时设成 10 秒用户点了停止按钮之后界面会僵住十秒看起来像卡死。设成 500 毫秒又在相机帧率低的时候频繁误报。2000 毫秒是个能兼顾两头的经验值。3.3 注册回调与取帧托管委托为什么必须用字段持有回调注册是整份 Demo 里最容易写出「偶发崩溃」的地方。先看正确写法// 关键委托实例必须由字段持有不能写成匿名方法直接传进去 private DevSdk.FrameCallbackHandler _frameCallback; private void StartGrab() { // 保存引用防止被 GC 回收 _frameCallback OnFrameArrived; // 注册回调最后一个参数是用户上下文指针这里不用就传 IntPtr.Zero _channel.RegisterCallback(_frameCallback, IntPtr.Zero); // 开始采集前面所有配置必须在这之前完成 _channel.StartAcquisition(); } private void OnFrameArrived(IntPtr pData, int width, int height, int stride, ulong frameNo, IntPtr userContext) { // 注意这段代码跑在 SDK 自己的采集线程上不是 UI 线程 // 不要在这里碰任何控件也不要在这里做耗时处理 try { FrameBuffer buffer _pool.Rent(width, height, stride); buffer.FillFrom(pData, width, height, stride, frameNo); FrameArrived?.Invoke(this, new FrameEventArgs(buffer)); } catch (Exception ex) { // 回调里抛异常会被吞掉或者直接崩进程必须自己兜住 GrabError?.Invoke(this, ex); } }这段代码有三处必须理解到位。第一委托实例要存到字段。托管封装层通常只是把委托的指针传给原生层原生层并不知道还有一个托管对象引用着它。如果委托是以临时变量的形式传进RegisterCallback方法返回后这个委托就没有任何强引用了GC 一回收原生层再回调过来就是一个野指针表现为AccessViolationException或者进程直接消失而且完全不告诉你发生在哪一行。这种崩溃属于典型的「黑匣子」故障日志里什么都看不到。第二OnFrameArrived运行在 SDK 的线程上。这个线程的调度优先级往往不低一旦你在里面做了耗时操作比如写文件、跑算法、更新界面下一次回调就会被推迟SDK 那边的队列开始堆积最终演变成丢帧。所有重活都应该转交给别的线程去做。第三回调内部一定要自己 try-catch。异常从回调里逃逸出去跨过托管边界的时候行为是未定义的有的封装层会吞掉有的会让整个进程退出还有的会让后续回调全部停止但设备仍在采集状态。三种表现都很讨厌前两种让你找不到原因第三种让你以为设备坏了。3.4 一帧数据的落地从 IntPtr 到 Bitmap回调拿到的是裸指针要变成能显示的东西得自己拷贝。这一步是把stride和width分清楚的地方public void FillFrom(IntPtr pData, int width, int height, int stride, ulong frameNo) { // Bitmap 按 8 位索引色创建Mono8 正好对应 _bitmap new Bitmap(width, height, PixelFormat.Format8bppIndexed); // 8bppIndexed 必须自己设调色板否则显示出来是伪彩色 ColorPalette palette _bitmap.Palette; for (int i 0; i 256; i) palette.Entries[i] Color.FromArgb(i, i, i); _bitmap.Palette palette; Rectangle rect new Rectangle(0, 0, width, height); BitmapData bmpData _bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); try { // 逐行拷贝源和目标的每行字节数都可能不等于 width int copyBytes Math.Min(width, stride); for (int y 0; y height; y) { IntPtr src IntPtr.Add(pData, y * stride); IntPtr dst IntPtr.Add(bmpData.Scan0, y * bmpData.Stride); CopyMemory(dst, src, copyBytes); } } finally { _bitmap.UnlockBits(bmpData); } FrameNumber frameNo; } // P/Invoke 声明比 Marshal.Copy 快一个量级 [System.Runtime.InteropServices.DllImport(kernel32.dll, EntryPoint RtlMoveMemory)] private static extern void CopyMemory(IntPtr dest, IntPtr src, int length);stride是这一段的全部重点。它表示「每行像素实际占用的字节数」为了内存对齐这个值通常大于width。比如宽度 1280 的 Mono8 图像stride可能是 1280也可能是 1536取决于 SDK 的对齐策略。如果你直接用width当行宽去拷贝第二行开始就会错位屏幕上表现为图像下半部分斜切或者重复很多人第一反应是相机坏了其实是这几行代码写错了。另一个点是Format8bppIndexed的调色板。索引色位图的像素值只是索引真正的颜色在调色板里默认调色板是彩色的所以不设调色板的话一张纯灰度图会显示成花花绿绿的样子。这个现象很迷惑人因为图像内容是对的只是颜色不对。拷贝用RtlMoveMemory而不是Marshal.Copy是因为后者对byte[]的重载更友好而这里两头都是IntPtr直接走内存搬移更干净。copyBytes取width和stride的较小值是为了防止某些 SDK 在最后一帧给的行宽信息不一致导致越界读。4. 显示与线程回调线程绝不能碰控件4.1 线程模型SDK 线程、UI 线程与 Invoke 的代价一份跑得起来的 Demo 和一份跑得稳的 Demo差别往往就在线程模型上。先把三条线程的职责说清楚SDK 采集线程由 SDK 内部创建按帧率触发回调。不能阻塞不能碰控件不能做重活。UI 线程唯一能改控件属性的线程。它同时也是消息泵一旦被阻塞窗口就会被系统标记为「未响应」。业务线程你自己起的用来跑检测算法、写图、发结果。可选但一旦有算法基本都需要。最省事的写法是在回调里直接Invoke回 UI 线程更新 PictureBox。这在低分辨率低帧率下能跑一旦帧率上来就会出问题每一帧都要排队等 UI 线程处理UI 线程处理不过来Invoke就会堆积消息队列越排越长界面的响应延迟肉眼可见地变差最后连点按钮都没反应。正确做法是让 UI 线程按自己的节奏去取数据而不是被数据推着走。我习惯的做法是回调只负责把帧放进一个「最新帧槽位」UI 定时器每 33 毫秒去取一次取到什么显示什么中间的帧直接覆盖掉。// 回调线程只做深拷贝和槽位替换不通知 UI private void OnFrameArrived(IntPtr pData, int width, int height, int stride, ulong frameNo, IntPtr user) { FrameBuffer buffer _pool.Rent(width, height, stride); buffer.FillFrom(pData, width, height, stride, frameNo); // 原子替换最新帧旧帧归还池子 FrameBuffer old Interlocked.Exchange(ref _latestFrame, buffer); if (old ! null) _pool.Return(old); // 没被消费的帧直接丢弃这就是主动丢帧 } // UI 线程定时器按固定频率取 private void RenderTimer_Tick(object sender, EventArgs e) { FrameBuffer frame Interlocked.Exchange(ref _latestFrame, null); if (frame null) return; _canvas.Show(frame); // 自绘控件见 4.2 _pool.Return(frame); UpdateFpsLabel(); }这段代码里的Interlocked.Exchange是整个设计的核心。它保证了「任意时刻只有一个最新帧」这个语义并且丢弃中间帧的动作是主动的、可控的。工业检测场景下显示出来的图像本来就不需要每一帧都看人眼 30fps 就到顶了相机跑 60fps 甚至 200fps 的时候多余的帧显示出来也没人看得见。把它们丢掉换取 UI 的流畅这笔账怎么算都划算。需要注意的是被丢掉的是显示用的帧业务处理用的帧不能走这条路否则算法那边会漏检。所以我在实际项目里会把这两条路分开显示用最新帧槽位业务用一个有界队列队列满了就报警绝不静默丢弃。4.2 自绘控件与 Bitmap 复用LockBits 的正确打开方式显示控件不用PictureBox原因很简单PictureBox每次赋Image都会触发一次重绘和一次内部资源管理帧率高的时候 GC 压力很大。自己继承Control重写OnPaint用Graphics.DrawImageUnscaled直接画控件的双缓冲打开效果干净得多。public class ImageCanvas : Control { private Bitmap _current; public ImageCanvas() { // 开启双缓冲消除闪烁 SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); // 减少重绘时的背景擦除 SetStyle(ControlStyles.Opaque, true); } public void Show(Bitmap bmp) { _current bmp; Invalidate(); // 只标脏不立即重绘 } protected override void OnPaint(PaintEventArgs e) { if (_current null) return; // 按控件大小做等比缩放保持像素不被拉伸变形 e.Graphics.InterpolationMode InterpolationMode.NearestNeighbor; e.Graphics.DrawImage(_current, ClientRectangle, 0, 0, _current.Width, _current.Height, GraphicsUnit.Pixel); } }InterpolationMode.NearestNeighbor这个设置对工业图像很重要。默认的双线性插值会把图像柔化边缘变得模糊看起来更「好看」但做缺陷检测的人一看就知道图被处理过了容易误判。最近邻插值保留硬边缘放大看像素块清清楚楚这才是调试该有的样子。Bitmap 的复用是另一个省 GC 的点。上面FillFrom里每帧都new Bitmap在 30fps 下就是每秒 30 个 Bitmap 对象每个都带一块非托管内存靠Dispose释放。如果哪条异常路径漏了Dispose内存就会慢慢往上涨而且是那种「跑一小时才看出来」的涨法。稳妥的做法是让FrameBuffer持有一个固定尺寸的 Bitmap尺寸变了才重建尺寸没变就复用同一块。public Bitmap GetOrCreateBitmap(int width, int height) { if (_bitmap ! null _bitmap.Width width _bitmap.Height height) return _bitmap; // 尺寸没变直接复用 _bitmap?.Dispose(); // 尺寸变了才重建 _bitmap new Bitmap(width, height, PixelFormat.Format8bppIndexed); ApplyGrayPalette(_bitmap); return _bitmap; }复用之后LockBits / UnlockBits的开销也降下来了因为不需要每次都重新分配那块非托管缓冲。这个优化在 500 万像素以上的相机上能省出可观的 CPU。4.3 帧率与丢帧统计把「看起来卡」变成可量化数字「画面有点卡」是一句没法排查的描述。我习惯在做完显示链路之后立刻加一组统计指标把主观感受换成数字// 每帧回调里累加每秒结算一次 private long _frameCount; private long _dropCount; private readonly Stopwatch _fpsWatch Stopwatch.StartNew(); private void OnFrameArrived(...) { Interlocked.Increment(ref _frameCount); FrameBuffer old Interlocked.Exchange(ref _latestFrame, buffer); if (old ! null) { // 槽位里还有没被消费的帧说明 UI 跟不上计一次主动丢帧 Interlocked.Increment(ref _dropCount); _pool.Return(old); } } private void UpdateFpsLabel() { if (_fpsWatch.ElapsedMilliseconds 1000) return; long frames Interlocked.Exchange(ref _frameCount, 0); long drops Interlocked.Exchange(ref _dropCount, 0); double secs _fpsWatch.Elapsed.TotalSeconds; _fpsWatch.Restart(); _lblFps.Text $采集 {frames / secs:F1} fps / 显示丢帧 {drops}; }这三个数字放在一起看才有意义。如果采集帧率和相机标称一致、显示丢帧为零说明链路健康采集帧率低于标称问题在相机配置、曝光时间或者触发频率采集帧率正常但显示丢帧很高问题在显示或业务线程太重。两种情况的排查方向完全相反没有这组数字就只能瞎猜。提示统计代码本身也有开销Interlocked在极高帧率下会成为瓶颈每秒几万帧的线扫场景尤其明显。线扫相机建议改成每 N 帧统计一次或者用long的普通自增加一次周期性读取允许少量误差。5. 避坑排查不出图、花屏、内存涨的排查清单5.1 打开设备报「设备被占用」现象OpenDevice返回失败或者抛异常提示设备已被占用、无法访问但任务管理器里看不到别的进程。原因采集卡是独占资源同一时刻只允许一个进程持有。最常见的是厂商的示例程序没关干净或者上一次调试崩掉的进程还挂在后台也有可能是某个后台服务比如相机厂商的守护进程在开机时自动启动了。还有一种隐蔽情况自己的程序里OpenDevice被调用了两次第一次的句柄没有释放。解决先在任务管理器里按名称排序把所有带厂商前缀的进程全部结束再重试。如果还是不行重启一次机器把独占状态彻底清掉。代码层面检查OpenDevice是否被重复调用所有设备句柄都放进using或者显式Close异常路径上用 try/finally 保证释放。5.2 设备打开了但一帧都收不到现象枚举正常、打开正常、StartAcquisition也不报错但回调一次都没进来界面上什么都不显示。原因按概率排序第一位是采集模式配成了触发模式但没有任何触发信号。Camera Link 采集卡在硬触发模式下会一直等外部信号等不到就永远不出帧。第二位是相机本身没有上电或者 Camera Link 线缆没拧紧接头有螺丝的那种松半圈就会间歇性丢帧。第三位是像素时钟或相机配置没有下发给相机——有些采集卡需要在 SDK 里显式执行一次「配置相机」的动作。解决先把采集模式改成 Continuous、触发源设成 None让卡自己产生节拍。这一招能排除掉绝大部分触发相关的问题。改完还是不出帧就去厂商的示例程序里试同一台相机示例能出图说明是代码问题示例也不出图说明是硬件或线缆问题。Camera Link 线缆的螺丝必须拧到底这一点别嫌麻烦。5.3 图像上半部分正常下半部分错位或者花屏现象图像的第一行或者前几十行看起来是对的往下开始斜切、重复、出现横向条纹。原因stride和width混用了。SDK 回调里给的stride是对齐后的行字节数通常大于width。如果在逐行拷贝时用width当行偏移从第二行开始源指针的偏移量就少了读到的内容整体左移表现出来就是斜切。另一个可能是像素格式不匹配相机出 Mono12 你配 Mono8每两个字节才是一个像素。解决拷贝时源指针用stride偏移目标指针用BitmapData.Stride偏移这是两个不同的值都不能省。然后打印一次回调参数把width、height、stride和相机的实际分辨率、位深对一遍。如果stride明显大于width比如 1280 对应 1536那就是对齐填充属于正常现象。5.4 跑十几分钟内存持续上涨最后 OutOfMemory现象程序启动时内存占用两百多兆跑十分钟涨到一G跑一小时直接崩。任务管理器里能看到内存在阶梯式上升。原因Bitmap 或者帧缓冲没有释放。new Bitmap背后是一块非托管内存GC 只回收托管的那层壳非托管部分要靠Dispose显式释放。如果回调里每帧都 new 一个 Bitmap异常路径上又漏了 Dispose那涨得会非常快。另一个可能是事件订阅没有解绑采集对象被 UI 窗体持有窗体关了但对象还活着。解决改成 Bitmap 复用见 4.2尺寸不变就不重建。FrameBuffer实现一个简单的对象池Rent/Return配对使用池子容量设个上限超出就直接Dispose。回调里所有分支都用 try/finally 包住确保异常路径上也会归还。事件订阅在窗体FormClosing里显式-不要指望弱引用事件帮你兜底。5.5 换成 x64 编译之后直接起不来现象项目属性里把平台从 x86 改成 x64编译通过一运行就在第一次调用 SDK 的时候抛异常异常类型是BadImageFormatException或者DllNotFoundException。原因厂商的托管封装 dll 或者原生 dll 只有 32 位版本你的进程是 64 位加载器直接拒绝加载。反过来也一样64 位 dll 塞进 32 位进程同样报BadImageFormatException。异常信息里提到的是托管 dll 的名字真正的元凶是它内部引用的原生 dll 位数不对。解决打开lib目录看一眼x86 和 x64 两个子目录里各自有什么如果只有一个目录有文件那就是只支持一种位数。这时候别硬改老老实实让整个解决方案统一到那个位数上。如果两个目录都有检查输出目录里是不是只有一份 dll混着放会导致加载器挑错。还有个补充手段用corflags或者反编译工具看一眼托管 dll 的位数标记比猜快得多。6. 二次开发把 Demo 变成能进产线的采集模块Demo 到产线之间隔着一层「可替换性」。Demo 里通常是直接 new 出厂商的类主窗体直接调StartAcquisition。产线上你迟早会遇到换卡、换相机型号、加一路相机、或者在没有硬件的情况下跑单元测试的需求这时候直接耦合的代码就得整体重写。改造的第一步是抽接口把「采集」这个动作定义成一组与厂商无关的契约public interface IFrameGrabber : IDisposable { event EventHandlerFrameEventArgs FrameArrived; event EventHandlerGrabErrorEventArgs GrabError; bool IsGrabbing { get; } GrabberStatistics Statistics { get; } // 帧率、丢帧数、累计帧数 void Open(GrabberConfig config); // 配置从 json 读不从窗体读 void Start(); void Stop(); void TriggerOnce(); // 软触发硬触发走硬件线 }接口里没有出现任何厂商类型GrabberConfig也是自己定义的 POCO字段就是 3.2 里那几项。这样CG300Demo.Grab项目里的Cg300Grabber只是其中一个实现将来换成别家采集卡只需要再写一个实现类UI 和业务一行都不用动。没有硬件的时候写一个FileReplayGrabber从tools/FrameDump存的 raw 文件里循环读单元测试和算法调试全靠它。第二步是把配置外部化。config/camera.json里存像素格式、触发源、缓冲帧数、超时、曝光时间程序启动时读改动之后重启生效。这样现场调整参数不用重新编译发版尤其是工控机没网络、发版要拿U盘跑一趟的情况下这个设计能救命。加个配置版本号字段将来结构变了可以做迁移。第三步是错误处理的分级。设备层的错误打开失败、超时、丢帧往上抛成事件让上层决定是重试、告警还是停机业务层的错误算法异常、写盘失败在业务线程内部消化不要影响到采集链路。我在实际项目里的做法是连续 3 次采集超时就自动停流并触发一次重连重连失败再上报告警。这个策略写死在采集模块里上层不用关心。验证方法我有两条固定的。第一条是空跑把相机盖子盖上或者干脆不接相机用文件回放模式跑满 8 小时看内存曲线是否平稳、丢帧计数是否为零、有没有未捕获异常。这条能抓住 90% 的资源泄漏问题。第二条是抖动测试在采集过程中故意每隔几秒弹一个模态对话框阻塞 UI 线程看采集模块是否还能正常缓冲、恢复之后是否丢帧、丢帧计数是否准确。这条能验证缓冲帧数这个参数到底够不够。提示别在回调线程里做日志落盘。文件 IO 的抖动是不可预测的一次磁盘忙可能就卡住几百毫秒直接导致丢帧。日志先进内存队列由独立线程批量写。从那以后我每次拿到一个新的采集卡 Demo都强制先跑一遍「空跑 8 小时 抖动测试」再谈功能开发这一步省掉的返工比它花的时间多得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑