C#调用Halcon工业相机采集与HSmartWindowControl缩放平移

发布时间:2026/11/6 4:26:38
C#调用Halcon工业相机采集与HSmartWindowControl缩放平移

简介:面向具备C#与Halcon基础的机器视觉、工业检测开发者,这份示例工程围绕海康工业相机的图像采集与交互查看展开,演示如何调用Halcon接口打开设备、抓取图像,并在HSmartWindow控件中完成显示、鼠标拖拽平移和滚轮缩放,可作为视觉软件中相机模块的基础实现参考。压缩包共46个文件、约22.53MB,含10个cs源码以及sln、csproj工程文件,另有HalconDotNet等5个dll、3个exe、config与resx等配置和资源文件,目录按解决方案、属性、调试输出组织,便于在Visual Studio中直接打开、编译与对照调试。已有931人学习,适合借鉴其相机连接、图像刷新、显示窗口初始化和交互事件处理的代码组织方式。对低速采集项目可直接参考,对高帧率场景也能据此扩展多线程或异步处理,并为工业自动化、质量检测等实时看图需求提供起步模板。

1. 相机能出图,缩放却往左上角跑——先把这个场景说清楚

调试台上最常听到的一句反馈是:「图像出来了,但鼠标一滚,画面就自己往左上角溜」。这不是相机的问题,也不是 C# 的问题,而是 C# 采图、Halcon 算子处理、HSmartWindowControl 显示这三层之间的坐标系没有对齐。这个项目干的正是把三件事串成一条线:用 C# 拉取国产 GigE 工业相机的实时流,把原始帧转成 HObject 交给 Halcon,再在 HSmartWindowControl 上做可交互的平移与缩放。适合两类人:一类是刚接手视觉工位、需要在一周内把相机画面调出来的新手;另一类是手里已有 Halcon 算法、只缺一个能拖能缩的看图控件的熟手。相机型号换了、分辨率换了,代码骨架基本不用动。

2. 项目结构与技术选型:从取流到上屏的四层拆分

这套东西如果一开始都塞进 Form1.cs,写到五百行就会变成黑匣子,改一个曝光参数要找十分钟。比较稳的做法是按「相机 → 转换 → 处理 → 显示」四层切开,层和层之间只传 HObject 和少量元数据,谁出问题一眼能定位。

2.1 四层分层与目录清单

工程按职责切目录,不按文件类型切,新人进来直接找对应层就行。

目录职责关键类型
Acquisition/相机连接、参数下发、取流回调、断线重连ICameraSource、VendorGigECamera、FrameArrivedEventArgs
Interop/原始帧指针到 HObject 的转换、颜色空间处理HImageFactory、PixelFormat
Processing/Halcon 算子封装,标定、找边、模板匹配Inspector、CalibrationMap
View/HSmartWindowControl 封装、平移缩放、ROI 交互HalconViewControl、ViewTransform
Config/相机参数、显示参数的 JSON 落盘CameraConfig、ViewConfig

Interop/单独拎出来是有原因的:这一层是所有内存问题的发源地。把GenImage1、GenImage3、指针拷贝集中到一个文件里,后面查内存泄漏只要盯这一个地方。我一般会在这一层强制要求「进多少帧、出多少帧、释放多少帧」三个计数器必须对齐,跑一晚上对不上就直接定位。

四种层的调用方向是单向的:Acquisition只发事件,不认识 Halcon;View只吃 HObject,不认识相机 SDK。中间由主窗体做粘合。这个约束看起来啰嗦,但它换来的好处是:换相机品牌只需要重写Acquisition,换显示控件只需要重写View,另外两层不动。

2.2 Halcon 直连相机与厂商 SDK 取流的取舍

标题里有「C# 调用 Halcon 读取相机」,实际上这条路有两条分支,很多人到这一步就开始翻车,因为选错了后面全是麻烦。

第一条是 Halcon 自带的图像采集接口,一句open_framegrabber就能拿到图像,代码量最小:

// Halcon 直连 GigE 相机:代码最短,但可控性也最弱 HOperatorSet.OpenFramegrabber( "GigEVision2", // 接口名,需要本机安装并授权对应的采集接口 0, 0, 0, 0, 0, 0, "progressive", // 场模式,面阵相机一般用 progressive -1, // 位深,-1 表示沿用相机默认 "default", // 色彩空间 -1, // 通用参数 "false", // 外部触发,false 表示自由运行 "default", // 端口 "cam1", // 设备标识,多相机时按枚举结果改 0, -1, out HTuple acqHandle); HOperatorSet.GrabImage(out HObject image, acqHandle); hSmartWindowControl1.HobjectToHimage(image);

OpenFramegrabber的参数里,-1大多表示「沿用默认」,"cam1"是设备标识,需要先用info_framegrabber枚举。这条路的好处是代码短、跟 Halcon 生态贴合;坏处是参数控制粒度受限于接口实现,改触发源、改 IO 输出、读相机温度这类操作基本没法做,而且采集接口本身需要单独授权。

第二条是走相机厂商的 .NET SDK 取流,再转成 HObject。代码多写一百多行,但相机的每一个 GenICam 节点都在手里。

维度Halcon 直连厂商 SDK + 转换
初始代码量约 20 行约 200 行
曝光/增益/触发控制受接口实现限制全量节点可读写
硬触发与 IO 输出弱完整支持
断线重连需自行封装SDK 提供事件
授权依赖需采集接口授权只需 Halcon 基础库
帧率上限高高,但要自己管内存

工业现场我一般选第二条。原因很实际:产线上的相机基本都要接光电触发,还要在检测完成后给 PLC 一个 IO 信号,这两件事用直连方案做起来很别扭。而转换成本其实不高,一次写好后所有项目复用。

2.3 线程模型:三条线程各管一段

相机 SDK 的回调线程是它自己的线程池线程,不是 UI 线程,也不是你 new 出来的线程。这一点必须先想清楚,否则后面一定会在某个位置抛跨线程异常。

项目里的分工是三条线:SDK 回调线程只做「取指针 + 打时间戳 + 塞最新帧」,不做任何转换;处理线程从最新帧槽位取图,做 HObject 转换和算法;UI 线程只负责DispObj。三条线之间用「最新帧槽位」而不是队列传递,队列在视觉项目里几乎一定会积压,积压的结果就是画面越跑越延迟,最后延迟三秒,操作员以为相机坏了。

3. 采集链路:从 GigE 取流到 HObject 的落地实现

采集这一段是整个项目里最容易出玄学的地方。相机连上了、能出图,但跑十分钟掉线一次;或者帧率上不去,标称 30fps 实测只有 12fps。这些问题八成不在 Halcon,而在取流和转换这两步。

3.1 相机抽象与参数下发

先定接口,把厂商 SDK 挡在接口后面。这样主窗体永远只面对ICameraSource,测试时还能塞一个假实现跑离线图片。

public interface ICameraSource : IDisposable { // 帧到达事件,回调线程触发,订阅方禁止在此直接操作 UI event EventHandler<FrameArrivedEventArgs> FrameArrived; bool IsGrabbing { get; } void Open(CameraConfig cfg); // 连接 + 下发参数 void StartGrabbing(); // 开始取流 void StopGrabbing(); // 停止取流,保留连接 } public sealed class FrameArrivedEventArgs : EventArgs { public IntPtr Data; // 原始帧首地址,生命周期由 SDK 管理 public int Width; public int Height; public int Stride; // 每行字节数,RGB 时通常是 Width * 3 对齐后 public bool IsMono8; // true 为灰度,false 为 RGB24 public long FrameId; // 相机侧帧号,用于统计丢帧 public DateTime Timestamp; }

接口里把Data设计成IntPtr而不是byte[],是为了避免每帧一次几 MB 的托管堆拷贝。灰度 500 万像素的图,一帧 5MB,30fps 就是 150MB/s 的拷贝量,GC 会被打得很惨。用指针直接喂给GenImage1,Halcon 内部自己拷贝一次,总共一次拷贝就够了。

参数下发按配置对象走,不要散落在代码里:

参数典型值说明
ExposureTime3000 ~ 8000 us取决于光源和运动速度,先定曝光再调增益
Gain0 ~ 5 dB增益会放大噪声,优先加光源
TriggerModeOff / On自由运行用 Off,产线接光电用 On
TriggerSourceLine0硬触发接在相机的输入线上
AcquisitionFrameRate20 ~ 30超过处理能力就往下降,别硬撑
PacketSize8192千兆网卡配巨帧时才能拉满

下发顺序也有讲究:先停流、再改参数、再起流。运行中直接改曝光,部分 SDK 会返回错误码,另一部分会静默失败,后者更麻烦,你以为改了其实没改。

3.2 回调取流与最新帧槽位

回调函数里能做的事非常有限,记住三条:不碰控件、不转换图像、不阻塞。只做一件事——把最新一帧的引用换进去。

private FrameArrivedEventArgs _latest; // 最新帧槽位 private int _pending; // 0 表示空,1 表示有未消费帧 private long _dropped; // 被丢弃的帧数,用于监控 private void OnSdkFrameArrived(object sender, SdkFrame e) { // 回调线程:SDK 每次送帧都会进这里,必须尽快返回 var args = new FrameArrivedEventArgs { Data = e.Ptr, Width = e.W, Height = e.H, Stride = e.Stride, IsMono8 = e.PixelType == SdkPixelType.Mono8, FrameId = e.Id, Timestamp = DateTime.Now }; // 上一帧还没被处理线程取走,直接丢弃这一帧,绝不排队 if (Interlocked.Exchange(ref _pending, 1) == 1) { Interlocked.Increment(ref _dropped); return; } Volatile.Write(ref _latest, args); // 保证处理线程能读到完整对象 _frameReady.Set(); // 唤醒处理线程 }

Interlocked.Exchange是这里的关键。它在一行里完成了「置位并返回旧值」,如果旧值已经是 1,说明处理线程还没取走上一帧,这一帧就直接丢掉。丢帧不可怕,延迟才可怕。_dropped计数器是排查问题的第一手资料:显示帧率掉到 10fps 时,先看它是丢帧导致的还是处理慢导致的,两者的处理方向完全相反。

处理线程侧对应的是:

private void ProcessLoop() { while (!_cts.IsCancellationRequested) { if (!_frameReady.WaitOne(100)) continue; var frame = Volatile.Read(ref _latest); if (frame == null) continue; Interlocked.Exchange(ref _pending, 0); // 先释放槽位,允许下一帧进来 HObject image = null; try { image = HImageFactory.FromRaw(frame); // 指针 -> HObject var result = _inspector.Run(image); // 算法层 _view.UpdateImage(image, result); // 交给显示层,内部切 UI 线程 } finally { image?.Dispose(); // 必须释放,Halcon 句柄不走 GC } } }

finally里的Dispose不是可选项。HObject在 HalconDotNet 里包着一个非托管句柄,GC 只回收托管壳子,句柄要等Finalize才释放,而Finalize线程根本跟不上 30fps 的节奏。跑两小时内存涨到几个 G,基本都是这一行漏了。

3.3 指针到 HObject 的转换细节

转换这一步只有两个函数,但坑不少。灰度图用GenImage1,彩色图要么拆通道,要么用GenImage3。

public static class HImageFactory { public static HObject FromRaw(FrameArrivedEventArgs f) { HObject image; if (f.IsMono8) { // 灰度:直接拿指针构造,Halcon 内部会拷贝一份,外部缓冲区归还 SDK 即可 HOperatorSet.GenImage1(out image, "byte", f.Width, f.Height, f.Data); } else { // RGB24:SDK 通常返回交错排列的 BGR 或 RGB, // 先用 gen_image_interleaved 拆成三通道再合并,避免手算偏移 HObject r, g, b; HOperatorSet.GenImageInterleaved(out r, f.Data, "bgr", f.Width, f.Height, -1, "byte", f.Width, f.Height, 0, 0, -1, 0); HOperatorSet.CountChannels(r, out HTuple ch); if (ch.I == 3) { HOperatorSet.Decompose3(r, out HObject c1, out HObject c2, out HObject c3); r.Dispose(); HOperatorSet.Compose3(c1, c2, c3, out image); c1.Dispose(); c2.Dispose(); c3.Dispose(); } else image = r; } return image; } }

GenImageInterleaved的"bgr"这个参数跟相机输出格式强相关,多数国产 GigE 相机默认是 BGR 顺序,但有些型号可以配成 RGB,配错了颜色就会红蓝互换,看起来「颜色发紫」。这个现象我见过不止一次,排查时先拿一张纯红物体对着拍,如果显示成蓝色,就是这个参数的问题。

另外GenImage1传指针时,SDK 缓冲区在回调返回后可能被复用,所以必须确认 Halcon 已经完成拷贝再归还。GenImage1是同步拷贝,函数返回时数据已经落到 Halcon 自己的内存里,回调里直接调用是安全的。但如果你改成异步转换,就必须在转换完成后再归还缓冲区,否则会拿到半帧新数据的诡异图像。

4. 显示与交互:HSmartWindowControl 上的缩放平移实现

前面都是准备工作,这一节才是标题里最核心的部分。HSmartWindowControl 自带HMouseWheelZoom和HMoveContent两个属性,打开就能拖能缩,看着挺省事。但只要你把这两个属性设成true,再去接管鼠标事件做 ROI 绘制,就会立刻发现两者打架:框选的时候画面跟着跑。所以这个项目里我把它俩都关掉,自己实现,可控性高得多。

4.1 控件初始化与 part 的语义

先建立正确的心智模型:Halcon 窗口显示的不是「一张图」,而是「图像坐标系里的一个矩形区域」,这个矩形叫 part,由(row1, col1, row2, col2)四个值定义。缩放平移的本质就是改这四个数,图像本身一像素都没动。理解这一点,后面所有计算都是初中的比例换算。

private HWindow _window; private void InitWindow() { // 必须在句柄创建之后取 HalconWindow,构造函数里拿到的是 null _window = hSmartWindowControl1.HalconWindow; // 关闭内置交互,全部自己接管 hSmartWindowControl1.HMouseWheelZoom = false; hSmartWindowControl1.HMoveContent = false; // 关闭自动保持宽高比,否则像素与窗口的线性换算不成立 hSmartWindowControl1.KeepAspectRatio = false; _window.SetWindowParam("graphics_stack", "true"); // 支持叠加层,算法结果画在上面 _window.SetDraw("margin"); _window.SetLineWidth(2); _window.SetColor("green"); }

HalconWindow属性在控件句柄创建前是 null,放在窗体构造函数里必然空引用。稳妥位置是OnHandleCreated重写或Load事件。KeepAspectRatio设为false是为了让「窗口一个像素对应图像多少距离」在两个方向上保持一致,否则后面用比例换算会算歪。代价是窗口比例和图像比例不一致时图像会变形,但对标定之后的测量场景,这反而是正确的显示方式。

设置图像时还有一个高频翻车点:

// 错误做法:每帧都这么写,用户的缩放位置每秒被重置 30 次 hSmartWindowControl1.HobjectToHimage(image);

HobjectToHimage内部会调用SetPart把整个图像铺满窗口。你刚把它放大到某个缺陷位置,下一帧一到,视图直接弹回全图。正确做法是只在第一帧或图像尺寸变化时设 part,后续帧只画内容:

public void UpdateImage(HObject image, InspectionResult result) { if (InvokeRequired) { BeginInvoke(new Action(() => UpdateImage(image, result))); return; } HOperatorSet.GetImageSize(image, out HTuple w, out HTuple h); if (_lastW != w.I || _lastH != h.I) { // 尺寸变化(换相机、换分辨率)才重置视图 _lastW = w.I; _lastH = h.I; _window.SetPart(0, 0, h.I - 1, w.I - 1); ResetViewState(); } HOperatorSet.DispObj(image, _window); // 只画内容,不动 part DrawOverlay(result); // 叠加检测结果,同样不动 part }

DispObj的第二个参数是 HWindow,不涉及 part 变更,这是关键差别。很多人以为两者等价,实际上一个是「换图并重置视图」,一个是「往当前视图里画」。

4.2 滚轮缩放:以鼠标位置为锚点

缩放的直觉是「鼠标指哪儿放哪儿大」,数学上就是以鼠标对应的图像坐标为不动点,把 part 的四个边界按同一个系数缩放。

private const double ZoomIn = 0.8; // 小于 1 表示视野变小,即放大 private const double ZoomOut = 1.25; private void OnMouseWheel(object sender, MouseEventArgs e) { if (_lastW == 0) return; _window.GetPart(out HTuple r1, out HTuple c1, out HTuple r2, out HTuple c2); double row1 = r1.D, col1 = c1.D, row2 = r2.D, col2 = c2.D; // 鼠标当前指向的图像坐标(当前 part 语义下) _window.GetMpositionSubPix(out HTuple mr, out HTuple mc, out HTuple _); double anchorR = mr.D, anchorC = mc.D; double k = e.Delta > 0 ? ZoomIn : ZoomOut; // 以锚点为中心收缩/扩张四个边界 double nr1 = anchorR + (row1 - anchorR) * k; double nc1 = anchorC + (col1 - anchorC) * k; double nr2 = anchorR + (row2 - anchorR) * k; double nc2 = anchorC + (col2 - anchorC) * k; // 缩放下限:视野宽度小于 16 像素时不再放大,避免数值精度问题 if (nr2 - nr1 < 16 || nc2 - nc1 < 16) return; // 边界钳制,不允许把视野拖出图像外 ClampPart(ref nr1, ref nc1, ref nr2, ref nc2); _window.SetPart(nr1, nc1, nr2, nc2); }

GetMpositionSubPix返回的是当前 part 下的图像坐标,单位是像素,带亚像素精度。anchorR/anchorC就是这一帧的不动点。公式anchor + (old - anchor) * k的几何含义是:锚点距离原边界多远,缩放后仍保持同样的相对比例,所以锚点在屏幕上的位置不动。

ClampPart做两件事:把窗口限制在[0, height-1] × [0, width-1]内,同时保证窗口宽高不小于 16 像素。后者不是洁癖,视野小到几个像素时,浮点精度和GetMpositionSubPix的换算会开始抖,鼠标不动图像也在颤,看起来像相机在抖。

滚轮事件还有一个平台差异:WinForm 的MouseWheel只在控件获得焦点时触发。相机一跑起来,焦点跑到别处,滚轮就失灵。解决办法是在控件上挂MouseEnter时主动Focus(),或者在窗体层面用IMessageFilter拦截WM_MOUSEWHEEL,后者更稳但代码更多。

4.3 拖拽平移与坐标基准更新

平移看着简单——鼠标往右拖,图像往右走——但实现里最容易翻车的地方是「基准点什么时候更新」。

private bool _dragging; private double _lastR, _lastC; private void OnMouseDown(object sender, MouseEventArgs e) { if (e.Button != MouseButtons.Left || _lastW == 0) return; _window.GetMpositionSubPix(out HTuple r, out HTuple c, out HTuple _); _lastR = r.D; _lastC = c.D; _dragging = true; hSmartWindowControl1.Cursor = Cursors.Hand; } private void OnMouseMove(object sender, MouseEventArgs e) { if (!_dragging) return; _window.GetMpositionSubPix(out HTuple r, out HTuple c, out HTuple _); _window.GetPart(out HTuple r1, out HTuple c1, out HTuple r2, out HTuple c2); // 鼠标在图像坐标里的位移量,part 反向移动同样的量 double dr = _lastR - r.D; double dc = _lastC - c.D; double nr1 = r1.D + dr, nc1 = c1.D + dc; double nr2 = r2.D + dr, nc2 = c2.D + dc; ClampPart(ref nr1, ref nc1, ref nr2, ref nc2); _window.SetPart(nr1, nc1, nr2, nc2); // 关键:SetPart 之后同一个屏幕位置对应的图像坐标已经变了, // 必须在 SetPart 之后重新取基准,否则拖动会越来越快 _window.GetMpositionSubPix(out HTuple nr, out HTuple nc, out HTuple _); _lastR = nr.D; _lastC = nc.D; }

最后三行是这个功能里唯一的「后悔药」。GetMpositionSubPix的返回值依赖当前 part:part 一变,同一个鼠标屏幕位置对应的图像坐标就变了。如果你在SetPart之前保存基准点,下一帧算出来的位移会把「part 变化导致坐标漂移」也算进去,结果是越拖越快,鼠标移动一厘米图像跑十厘米。这个 bug 看起来像「鼠标加速」,实际是坐标基准没更新,我第一次遇到时盯了半小时才想明白。

另一个细节是拖出边界时的抖动。鼠标移到窗口外面,GetMpositionSubPix返回的是 part 边缘坐标,位移量变成 0 或者跳变,图像会一抽一抽。在ClampPart里把 part 限制住之后,这种情况会缓和很多,更彻底的办法是在MouseLeave时把_dragging置为false,松开拖拽。

再补一个双击复位,操作员最喜欢这个:

private void OnMouseDoubleClick(object sender, MouseEventArgs e) { // 双击恢复全图视野 _window.SetPart(0, 0, _lastH - 1, _lastW - 1); }

4.4 叠加层的坐标一致性

检测结果要画在图上:找到的边、匹配到的模板、测量出来的距离标注。这些绘制必须在同一套 part 语义下进行,否则一缩放就飞。

private void DrawOverlay(InspectionResult result) { if (result == null) return; // 叠加层用图形栈,不污染底层图像 _window.SetColor("green"); foreach (var edge in result.Edges) { // 直接用图像坐标画线,SetPart 已经处理了换算 _window.DispLine(edge.Row1, edge.Col1, edge.Row2, edge.Col2); } // 文字标注用 disp_message,默认跟随图像坐标 HOperatorSet.DispText(_window, $"OK {result.Score:F2}", "image", result.Row, result.Col, "green", "box", "false"); }

DispText的坐标系参数传"image"比"window"好:"window"用的是窗口像素坐标,图像一缩放,文字就跑到别处;"image"跟着图像坐标走,缩放时文字会跟着放大或缩小,位置始终对得上。

5. 排查记录:五个改写过多遍的坑

相机和显示凑在一起,问题很少出在单点上,基本都在接缝处。下面五条是这类项目里复现率最高的,每条都按现象、原因、解决写清楚。

5.1 视图复位与坐标偏移

第一条:缩放几秒后自动回满屏。现象是用户拖到某个角落放大看缺陷,画面每隔几秒或者每来一帧就跳回全图,反复拖几次之后操作员就不愿意用了。原因几乎总是显示函数里调了HobjectToHimage,这个函数内部会重置 part。另一条隐蔽路径是「图像尺寸变化检测」写错了,每帧都判定成尺寸变化,于是每帧都SetPart。解决办法是把「换图」和「重置视图」拆成两个方法:尺寸变化才SetPart,其余帧一律DispObj。排查时在SetPart前后各打一行日志,看它一秒钟被调了几次,一眼就能确认。

第二条:鼠标位置和图像对不上。现象是鼠标指着缺陷,画出来的框偏了几十像素,而且偏移量随缩放变化。原因是 DPI 缩放:Windows 显示设置开 125% 或 150% 时,控件的逻辑尺寸和物理像素不一致,GetMpositionSubPix的换算就歪了。解决办法是在应用清单里声明 DPI 感知,让应用自己处理缩放,而不是被系统拉伸。另外,如果控制代码里用了PointToClient自己算坐标,就必须把 DPI 系数乘回去。我一般的做法是:坐标一律从GetMpositionSubPix拿,宁可少写几行自算逻辑,也不碰 DPI 这个雷区。

第三条:拖拽越拖越快。现象是鼠标慢慢移动,图像飞速跑出视野。原因是SetPart改变了鼠标位置对应的图像坐标,而基准点没有跟着更新,每次移动都把「视野位移」又叠加了一次。解决办法是在SetPart之后重新调用GetMpositionSubPix刷新_lastR/_lastC。这条经验值得写进注释里,不然下一个人重构时很容易顺手删掉。

5.2 采集卡顿与资源泄漏

第四条:一拖拽界面就卡成幻灯片。现象是静止时画面流畅,一开始拖动画框,相机帧率骤降,鼠标拖动有明显延迟。原因是回调线程里用了Invoke(同步版)去更新 UI,UI 线程正在处理鼠标事件,回调线程就被阻塞,SDK 的内部队列很快填满,开始丢帧或者报超时。解决办法有两层:采集回调里绝对不碰 UI,只更新最新帧槽位;UI 更新用BeginInvoke而不是Invoke,并且加一个「已经在排队」的标记,避免排队堆积。另外,拖拽期间可以主动降显示帧率,只画不处理,鼠标松开后恢复正常,操作手感会好很多。

第五条:跑两三个小时内存涨到几个 G 然后崩掉。现象是启动时内存五六百兆,挂机一晚上变成三个 G,最后抛异常或者直接无响应。原因是HObject没有释放。这个类型在 HalconDotNet 里包着非托管句柄,GC 只回收托管部分,句柄要等Finalize,而Finalize线程的处理速度远跟不上 30fps。解决办法是把每个HObject的生命周期压到最短:能用using就用,不行就try/finally,尤其是算法中间产生的临时图(阈值结果、区域、轮廓),漏一个都是慢性泄漏。验证方法是挂机时用任务管理器盯着私有工作集,正常的曲线应该是一条平线,缓慢上升就说明有地方漏了。

顺带提一句相机连不上的问题,虽然不属于显示层,但排查时经常混淆。如果日志里是取流超时,先看触发模式是不是被上次调试改成了On却没接触发线,再看网卡巨帧有没有开、PacketSize是不是超过了网卡 MTU。这两个配置错一个,相机能连上、能读参数,就是一帧不出,很容易被误判成 Halcon 的问题。

6. 二次开发:把 Demo 收成一个可复用控件

这套东西最终要落到「拿来就用」上,我把它收成了两个东西:一个HalconViewControl用户控件,一个ICameraSource接口实现。控件对外只暴露UpdateImage(HObject)、ResetView()、AddDrawingObject(...)三个方法,内部把 part 管理、鼠标事件、DPI、叠加层全包住。窗体里剩下的代码就是订阅帧事件、调算法、调UpdateImage,一行显示相关的逻辑都不用写。这套封装换到另一个工位上,只改配置文件里的相机 IP 和曝光时间就能跑,这是它最大的价值。

改造时按这个顺序推进比较稳。第一步先把显示抽出来,把HobjectToHimage全部替换成DispObj加一次性的SetPart,这一步做完,视图乱跳的问题基本消失。第二步加 ROI 交互,用HDrawingObject在图像坐标上画矩形,SetPart变化时 drawing object 不需要重建,因为它的坐标本来就是图像坐标,这一点比自己维护屏幕坐标省事得多。第三步才做多相机,每个相机一个ICameraSource实例、一个处理线程,共用一个显示控件,切换靠下拉框,注意切换时要停掉旧相机的取流并释放它产出的 HObject。

验证方法我一般固定三个指标:连续跑两小时看内存曲线是否走平;用相机侧帧号和显示帧号做差,看积压是否稳定在 0 到 1 帧;缩放平移一百次之后,GetPart的返回值是否仍然落在图像范围内且宽高不为负。第三个指标能提前抓出绝大多数坐标计算错误,比肉眼观察可靠得多。

还有一个技巧值得单独说:把视图状态(row1/col1/row2/col2和当前显示模式)序列化进配置文件,下次打开时恢复上次的视野。做标定调试的时候,操作员每次都要重新缩放到同一个工位位置,来回几十次,人会烦。加上这个之后,重新打开软件直接就在上次的位置,省下的时间比写这段代码多得多。

从那以后我每次接新的视觉工位,都强制先跑一遍「连续两小时 + 缩放一百次 + 内存曲线」这三项,不管需求多急。有两次就是因为跳过了这一步,现场调试到半夜才发现是句柄没释放。希望这些能帮你少走一段弯路。

本文还有配套的精品资源,点击获取