简介:面向机器视觉与图像处理开发者的C#与Halcon联合编程工具源码,解决在WinForms应用中完成图像ROI区域交互式绘制、编辑与保存的痛点。控件完整实现图像平移缩放、直线、矩形、任意角度矩形、圆、椭圆五种ROI绘制模式,支持ROI选中删除与.hobj格式导出,可直接接入Halcon算法流程,适合具备一定C#基础、正在搭建图像标注或测量工具的中高级开发者参考。资源包共67个文件,以17个.cs源码文件为核心,搭配VS解决方案、DLL依赖、resources及resx等界面资源文件,整体仅360KB,轻量且结构紧凑。已有4462人学习下载。从预览目录看,源码覆盖主窗体(Form1)、图像显示窗体(HalconViewForm)与属性配置模块,右键菜单还支持ROI颜色(红绿蓝黄)及线样式设置,分层清晰,可帮助读者快速理解Halcon显示控件封装、鼠标交互事件分发与ROI列表管理逻辑,是集成机器视觉界面的实用范例。
1. 图像 ROI 编辑控件:为什么说它是视觉上位机的刚需
做视觉检测项目时,最磨人的往往不是算法调参,而是“换产品就要改检测区域”。一条产线今天测手机中框,明天测螺丝垫片,检测区域的位置、大小全变了。如果每次都在代码里改硬编码的 Row1、Col1、Row2、Col2,改完还要重新编译发布,调试工程师能崩溃。C# + Halcon 的图像 ROI 编辑工具控件,就是把这块重复劳动收进一个可视化组件里:在 C# 上位机的窗体上嵌入 Halcon 显示窗口,操作员直接用鼠标在图像上画矩形、圆、椭圆或任意多边形,画完能拖动、能缩放、能保存到配置,下次启动自动加载。这个方向适合两类人:一类是做通用视觉平台,想把 ROI 配置做成软件功能的;另一类是被项目里的区域反复调整逼疯,想给现有程序加个调试面板的。开发这类控件并不需要把 Halcon 的全部算子背下来,核心就三件事:搞清楚图像坐标和窗口坐标的映射、写一套干净的鼠标交互状态机、把 ROI 参数用可序列化的结构存起来。下面把我自己搭过的方案拆开讲,从底层映射到踩坑,最后给进阶思路。
2. 从显示到交互:ROI 控件的基础设计与坐标映射
2.1 HWindowControl 与图像显示的基本原理
HalconDotNet 里的 HWindowControl 是一个封装好的窗口控件,内部持有一个 HWindow 句柄,通过 htWindow.HalconWindow 可以调用绝大多数 Halcon 图形操作。显示图像最常用的写法是 HOperatorSet.DispObj(图像, 窗口句柄),但在显示之前必须先确定图像的显示窗口范围,否则图像会被拉伸或只显示一部分。这里的关键是 SetPart 算子,它决定了窗口里显示的是图像哪个像素区域。例如要显示一张宽 1280、高 1024 的图,可以调用:
HOperatorSet.SetPart(halconWindow, 0, 0, 1023, 1279); HOperatorSet.DispObj(image, halconWindow);SetPart 的参数是窗口左上角和右下角的图像坐标,注意是行、列顺序,不是宽度高度。如果你的图像在运行时可能变化,比如相机分辨率变了,建议每次加载图像后都重新调用 SetPart,并传入当前图像的实际宽高减一。只设置一次、后续图像换分辨率也不改的话,图像边缘会被截断,ROI 的坐标会跟着错。
HWindowControl 的事件机制和普通 WinForms 控件相同,鼠标事件可以直接挂到控件上,也可以挂到窗体上再用坐标转换。但有个容易忽略的细节:HWindowControl 内部还有一个反走样的绘图模式,用 HOperatorSet.SetDispMode 可以设置颜色和线宽,但不要在绘制过程中频繁切换,否则性能下降明显。我一般只在控件加载时设置一次:
HOperatorSet.SetColor(halconWindow, "green"); HOperatorSet.SetLineWidth(halconWindow, 2);2.2 ROI 数据模型:形状、参数与序列化结构
在写交互之前,先把 ROI 的数据结构定下来。Halcon 本身有 HRegion 和 HObject,但直接在 UI 里用 HRegion 做编辑会很别扭,因为它没有内置的“参数化”概念。常见做法是定义一套可序列化的 ROI 模型,每种形状保存自己的参数。比如:
public enum RoiType { Rectangle, Rectangle2, Circle, Ellipse, Polygon } public class RoiDefinition { public RoiType Type { get; set; } public double Row { get; set; } public double Col { get; set; } public double Radius { get; set; } // 圆用 public double Radius1 { get; set; } // 椭圆用 public double Radius2 { get; set; } // 椭圆用 public double Phi { get; set; } // 旋转角度 public double Row1 { get; set; } public double Col1 { get; set; } public double Row2 { get; set; } public double Col2 { get; set; } public List<PointD> PolygonPoints { get; set; } }为什么不用 HRegion 直接存?因为 HRegion 是像素层面的对象,保存和重建依赖图像分辨率,而且转成 JSON 时不好处理非托管内存。ROI 编辑控件的本质是让用户操作这些数值,再在需要时把数值转换成 Halcon 区域。所以模型层必须是纯托管类,不持有任何 Halcon ID。
序列化方面,我一般用 JSON。用 XmlSerializer 也可以,但 JSON 更易读,也方便和后续的配方文件合并。注意 double 列表在反序列化时的精度问题,一般业务场景足够。若对精度极度敏感,可以把坐标乘以 1000 存整数,需要亚像素再除以 1000。
2.3 控件坐标到图像坐标的映射与逆映射
这是 ROI 控件里最容易出错的环节。屏幕上鼠标点的是窗口像素坐标,而 ROI 存的是图像坐标。如果窗口显示区域和图像像素一一对应,没有缩放偏移,那映射就是平移;但如果做了缩放适应,就必须做换算。我的做法是统一维护一个映射函数:
private double _scaleX, _scaleY; private double _offsetX, _offsetY; private void RecalcImageMap(int imageWidth, int imageHeight, int controlWidth, int controlHeight) { _scaleX = (double)imageWidth / controlWidth; _scaleY = (double)imageHeight / controlHeight; _offsetX = _scaleX * 0.5; _offsetY = _scaleY * 0.5; } public PointD ControlToImage(Point p) { double row = p.Y * _scaleY + _offsetY; double col = p.X * _scaleX + _offsetX; return new PointD(row, col); } public Point ImageToControl(PointD p) { int x = (int)((p.Col - _offsetX) / _scaleX); int y = (int)((p.Row - _offsetY) / _scaleY); return new Point(x, y); }这里假设图像被拉伸填满整个控件。如果你要保持宽高比,缩放系数要取 min(_scaleX, _scaleY),同时还要计算图像居中时在控件里的偏移。更严谨的做法是使用 HOperatorSet.GetWindowExtents 拿到窗口当前显示范围,再和控件尺寸做计算。但手动维护并不难,反而更好调试。注意控件尺寸变化时必须重新计算,窗体的 Resize 事件里要调用一次。
3. 动手实现一个 ROI 编辑控件:从绘制到保存
3.1 初始化窗口与加载图像的最小子集
搭建控件的骨架,我习惯用用户控件(UserControl)而不是窗体,这样方便拖到任意页面。初始化时创建 HWindowControl,设置停靠方式为填充,然后在加载图像时调用初始化显示。下面是最小可用的加载图像代码:
public void LoadImage(string imagePath) { HOperatorSet.ReadImage(out HObject image, imagePath); _image = image; int width, height; HOperatorSet.GetImageSize(image, out width, out height); htWindow.HalconWindow.SetPart(0, 0, height - 1, width - 1); HOperatorSet.DispObj(image, htWindow.HalconWindow); RecalcImageMap(width, height, htWindow.Width, htWindow.Height); }需要注意的是 HObject 是非托管对象,在类里持有它时必须保证被 GC 正确回收。HalconDotNet 的 HObject 实现了 IDisposable,但不会自动释放底层的 Halcon 句柄,因此控件在切换图像或关闭时要主动调用 Dispose。如果频繁加载图像而不释放,内存会肉眼可见地增长,后面避坑章节会专门说。
窗口初始化时,建议把 HWindowControl 的 SizeMode 设置为 Normal,不要让它自动处理显示缩放。因为我们要自己控制坐标映射,任何自动缩放都会让映射失效。如果确实需要图像适应控件,就用上面的 _scaleX、_scaleY 手动换算。
3.2 让鼠标能画 ROI:绘制流程与状态机
鼠标绘制本质上是一个简单的状态机:空闲、绘制中、完成。在绘制状态下,按下鼠标记录起点,移动时实时刷新预览,抬起时生成最终 ROI。这里有个选择:预览用的图形是直接用 HRegion 显示,还是用 Halcon 的绘制算子?我强烈建议不用 draw_rectangle1 这类阻塞式算子,因为在 WinForms 里调用它们会阻塞 UI 线程,窗口会进入一种“Halcon 交互死循环”,很难和工具栏按钮配合。正确做法是自己在 MouseDown、MouseMove、MouseUp 里收集坐标,然后用 HOperatorSet.GenRectangle1 生成一个临时 HRegion 显示出来。
给一个矩形绘制的最小状态机:
private bool _isDrawing; private PointD _drawStart; private PointD _drawCurrent; private void OnMouseDown(object sender, MouseEventArgs e) { if (_currentMode == EditMode.DrawRectangle) { _isDrawing = true; _drawStart = ControlToImage(e.Location); _drawCurrent = _drawStart; } } private void OnMouseMove(object sender, MouseEventArgs e) { if (!_isDrawing) return; _drawCurrent = ControlToImage(e.Location); XLD = null; HOperatorSet.GenRectangle1(out HObject tempRoi, _drawStart.Row, _drawStart.Col, _drawCurrent.Row, _drawCurrent.Col); htWindow.HalconWindow.ClearWindow(); HOperatorSet.DispObj(_image, htWindow.HalconWindow); HOperatorSet.DispObj(tempRoi, htWindow.HalconWindow); tempRoi.Dispose(); }这段逻辑里有一个关键点:每次预览都要 ClearWindow 后重新显示图像和临时 ROI。如果图像很大,这样反复全图重绘会卡顿。优化方案是只在 ROI 变化区域做局部刷新,但实现复杂度会高很多。大多数工业图像尺寸在 500 万像素以下,全图刷新能到 30 帧以上,所以先不做局部优化,卡了再说。
MouseUp 里要把最终坐标整理成 ROI 对象,并加入列表。注意要防止用户画出一个只有几个像素的“废 ROI”,可以在抬起时判断宽高是否大于某个阈值,比如 3 像素,否则丢弃并提示。
3.3 选中、拖动与调整大小的交互实现
绘制完成后,用户最需要的是拖动 ROI 到新位置,或者调整边框大小。拖动的前提是命中测试:鼠标按下时,判断点击点是否落在某个 ROI 内部。这个测试可以基于 Halcon 的 HRegion,也可以用简单几何计算。我优先用几何计算,因为速度更快且不依赖非托管对象。
对于矩形,判断点是否在内部就是行列坐标的范围比较。对于旋转矩形,先把点转换到矩形局部坐标系再判断。下面是一个拖动矩形的示例:
public bool ContainsPoint(RoiDefinition roi, PointD p) { if (roi.Type == RoiType.Rectangle) { double minRow = Math.Min(roi.Row1, roi.Row2); double maxRow = Math.Max(roi.Row1, roi.Row2); double minCol = Math.Min(roi.Col1, roi.Col2); double maxCol = Math.Max(roi.Col1, roi.Col2); return p.Row >= minRow && p.Row <= maxRow && p.Col >= minCol && p.Col <= maxCol; } // 其他类型类似 return false; }拖动时,记录按下时鼠标坐标与 ROI 参考点(比如中心)的偏差,移动时用新鼠标坐标减去偏差得到新位置。代码结构如下:
private PointD _dragOffset; private RoiDefinition _draggingRoi; private void OnMouseDownForMove(object sender, MouseEventArgs e) { PointD imgPoint = ControlToImage(e.Location); var roi = HitTest(imgPoint); if (roi != null) { _draggingRoi = roi; _dragOffset = new PointD(imgPoint.Row - roi.Row, imgPoint.Col - roi.Col); } } private void OnMouseMoveForMove(object sender, MouseEventArgs e) { if (_draggingRoi == null) return; PointD imgPoint = ControlToImage(e.Location); _draggingRoi.Row = imgPoint.Row - _dragOffset.Row; _draggingRoi.Col = imgPoint.Col - _dragOffset.Col; RefreshRoiDisplay(); }调整大小则更复杂,需要判断鼠标命中在边缘还是角点。我的做法是在 ROI 对象里维护 8 个手柄(四个角、四条边中点),拖动时根据手柄类型更新对应的坐标参数。例如拖动左下角手柄时,更新 Row2 和 Col1。这个逻辑不难,但分支多,建议先画图把每种手柄对应的参数写清楚。
这里还有个交互模式切换的问题:同一套鼠标事件,既要做绘制又要做编辑,怎么区分?用一个枚举字段标记当前模式,在工具栏切换。绘制模式下 MouseDown 会创建新 ROI,编辑模式下 MouseDown 会先做命中测试。两个模式不能同时开启,这是控件设计的基本前提。
3.4 ROI 参数的保存与重新载入
保存 ROI 列表最省事的是序列化成 JSON 文件。注意序列化结构要包含形状类型和全部参数,不要遗漏旋转角度或圆角。我用 Newtonsoft.Json 的 JsonConvert 序列化 List ,输出到一个配置文件:
public void SaveRois(string configPath) { string json = JsonConvert.SerializeObject(_roiList, Formatting.Indented); File.WriteAllText(configPath, json, Encoding.UTF8); } public void LoadRois(string configPath) { if (!File.Exists(configPath)) return; string json = File.ReadAllText(configPath); _roiList = JsonConvert.DeserializeObject<List<RoiDefinition>>(json); RefreshRoiDisplay(); }重新载入后要重新生成对应的 HRegion 并显示。不要直接把序列化对象转成 HRegion 保存,因为那样会把图像坐标和区域粘连在一起。加载时最好校验一下 ROI 坐标是否在当前图像范围内,如果图像换了,老配置里的 ROI 可能越界。我一般会在 LoadRois 里加一个裁剪或丢弃的策略,具体在第四章展开。
4. 单测与边界:ROI 越界、缩小时变和与 Halcon 运算的对接
4.1 保证 ROI 不超出图像范围
用户拖动手柄时,可能会把矩形的一角拖到图像外面。如果放任不管,ROI 的坐标就会超出图像范围,后续调 reduce_domain 或 crop 时会得到空区域甚至异常。所以每次 ROI 坐标更新后,要做一次 Clamp。定义图像的矩形边界:
public RoiDefinition ClampRoi(RoiDefinition roi, int imageWidth, int imageHeight) { if (roi.Type == RoiType.Rectangle) { roi.Row1 = Math.Max(0, Math.Min(imageHeight - 1, roi.Row1)); roi.Col1 = Math.Max(0, Math.Min(imageWidth - 1, roi.Col1)); roi.Row2 = Math.Max(0, Math.Min(imageHeight - 1, roi.Row2)); roi.Col2 = Math.Max(0, Math.Min(imageWidth - 1, roi.Col2)); } // 圆形、椭圆需要保证圆心和半径都在范围内 return roi; }注意 clamp 不能改变用户的编辑意图,比如用户想保留一个超出边界的角作为临时参考。但在实际检测中,超出图像的部分没有像素数据,所以 clamp 是合理的。如果是旋转矩形,clamp 要更小心,因为旋转后边界可能与图像轴不平行。此时我会把四个角点都算出来,然后挨个 clamp 到图像矩形内,再反算参数。
4.2 与 Halcon 的 reduce_domain / crop 对接
ROI 编辑控件的最终目的是给后续算子使用。保存的 RoiDefinition 要能生成对应的 HRegion。以旋转矩形为例:
public HRegion CreateRegion(RoiDefinition roi) { switch (roi.Type) { case RoiType.Rectangle: return new HRegion(roi.Row1, roi.Col1, roi.Row2, roi.Col2); case RoiType.Rectangle2: { double row1 = roi.Row - roi.Radius1; double col1 = roi.Col - roi.Radius2; return new HRegion(row1, col1, roi.Row, roi.Col, roi.Phi); } case RoiType.Circle: return new HRegion(roi.Row, roi.Col, roi.Radius); case RoiType.Ellipse: return new HRegion(roi.Row, roi.Col, roi.Radius1, roi.Radius2, roi.Phi); default: return null; } }拿到 HRegion 后,配合图像就能做 reduce_domain:
HOperatorSet.ReduceDomain(image, region, out HObject reducedImage);这里有个容易忽视的坑:HRegion 构造函数的参数顺序和 Halcon 算子不一样。new HRegion(row1, col1, row2, col2) 里的 row1 是左上角行,col1 是左上角列,row2 是右下角行,col2 是右下角列。如果你用惯了 HOperatorSet.GenRectangle1,它的参数顺序是 (row1, col1, row2, col2)。两者是一致的,但注意矩形参数可能被用户画成从右下到左上,需要先归一化成左上<右下再构造。
此外,要时刻记住 HRegion 和 HObject 都是非托管资源。在检测的主流程里,每帧图像都会生成新的 region,用完后要 Dispose。如果 ROI 是固定配置,可以在控件加载时把 HRegion 缓存起来,每帧只做 ReduceDomain 和后续处理,这样能省掉每次生成区域的开销。
5. 避坑 / 常见问题 / 排查:ROI 控件开发中会遇到的 5 个实际坑
5.1 现象:拖动 ROI 后,它在图像上的位置和鼠标指针错位,偏移量还固定
原因是我最早直接拿鼠标在控件上的坐标去更新 ROI 的 Row/Col,忘了做 ControlToImage 的映射。如果窗口缩放比例不是 1,那么每移动一个像素,ROI 在图像里移动的就不是一个像素。表现就是越靠近图像边缘,偏移越明显。解决方法是把所有鼠标坐标先转成图像坐标,再参与计算,而不是用 System.Drawing.Point 直接赋值。上面 3.3 的代码已经体现了这一点,但很多人会在拖动偏移量上犯错,offset 必须是图像坐标,不能是控件坐标。
5.2 现象:窗口 Resize 之后,ROI 显示位置不对,但 ROI 的数据没变
原因是 Resize 后控件大小变了,但坐标映射没有重新计算。如果我们的 _scaleX、_scaleY 是基于控件 Width/Height 算的,就必须在 Resize 事件里调用 RecalcImageMap,否则映射错乱。另一个隐蔽点:HWindowControl 在 Resize 时可能会自动触发重绘,但这个重绘显示的是上一次 SetPart 的窗口内容,不会自动适配新尺寸。所以 Resize 后要重新调用 SetPart 和 DispObj,把图像按新控件的显示范围刷一遍。
5.3 现象:绘制的时候鼠标一移动窗口就闪烁,甚至看不到预览的 ROI
原因是 MouseMove 里每次都 ClearWindow,而 ClearWindow 会把整个窗口清成黑色,再重绘图像和 ROI,这个流程没有启用双缓冲。HWindowControl 本身不是双缓冲控件,频繁全图重绘会有闪烁。解决方法是降低重绘频率,例如鼠标事件里只更新临时 HRegion,然后用 HOperatorSet.SetSystem("flush_graphic", "false") 暂停窗口刷新,等鼠标移动结束再统一刷新。或者更省事:在 MouseMove 里只记录坐标,用一个定时器按 30Hz 刷新显示,把重绘次数压下来。我用的是两者结合:拖动过程中若图像小于 200 万像素就直接刷新,更大的图开定时器。
5.4 现象:程序长时间运行,内存占用一路涨,尤其切换图像后不返还
原因是 HObject、HRegion、HImage 这些对象没有及时 Dispose。很多人知道 C# 有垃圾回收,但 Halcon 的非托管句柄不受 .NET GC 直接管理。如果创建了临时变量,比如 GenRectangle1 生成的 HObject,用完必须 Dispose。进一步说,HOperatorSet.ReadImage 返回的 HObject 也一样,加载新图前要把旧图 Dispose。我写了一个工具方法:
public static void DisposeObject(ref HObject obj) { if (obj != null && obj.IsInitialized()) { obj.Dispose(); obj = null; } }在 LoadImage 的开头先 Dispose 旧图,再读新图,这样能避免内存泄漏。如果你发现内存涨,用任务管理器看句柄数,如果句柄数持续上涨,基本就是 HObject 泄漏,回到代码里把每个局部变量检查一遍。
5.5 现象:在后台线程里更新 ROI 或调用 DispObj,程序直接崩溃或卡死
原因是 HWindow 的访问不是线程安全的。HWindowControl 和 Halcon 窗口句柄都由 UI 线程持有,如果你在 Task 或 Thread 里调用它,会破坏窗口内部状态。我遇到过在相机采图线程里想实时刷新 ROI 叠加层,结果画面撕裂、程序退出。正确做法是使用 Control.BeginInvoke 或 SynchronizationContext.Post 把显示调用封送到 UI 线程:
private void RefreshRoiFromWorker() { if (htWindow.InvokeRequired) { htWindow.BeginInvoke(new Action(RefreshRoiDisplay)); return; } RefreshRoiDisplay(); }注意 BeginInvoke 是异步的,如果连续调用多次,UI 线程可能堆积消息,建议用标志位合并刷新请求,比如只保留最新一次。
6. 进阶用法:多 ROI、形状约束与检测流程的无缝集成
当你把基础的绘制、拖动、保存跑通后,控件已经能应付大多数项目。剩下值得投入的是三个扩展点。
第一个扩展点是多 ROI 的管理。视觉检测通常需要不止一个区域:定位基准一个,缺陷检测一个,字符识别还要一个。我的做法是把 ROI 列表放到一个 BindingList 里,并绑定到右侧的 ListBox,点击条目就能在图像上高亮对应的 ROI。这样操作员看着左边图像、右边列表,能快速理解当前配置。删除、复制、重命名这些操作都基于列表索引,比在图像上直接删要安全。
第二个是形状约束。有些场景下 ROI 不是任意画的,比如只能画固定宽度的矩形,或者旋转角度必须保持 0/90/180/270。给 RoiDefinition 增加一个 Constraint 字段,在鼠标抬起时把参数修正到最近的合法值。比如角度约束为 0 度,可以把 Phi 设置成 0,并把 Radius2 作为宽度。这个特性在夹具固定的产线上特别有用,能防止操作员误触导致 ROI 变成奇怪的平行四边形。
第三个是和检测流程的集成。我通常把 ROI 编辑控件编译成一个独立 DLL,对外暴露一个 GetDetectRegions 方法,返回 List 。检测线程在每帧图像上先获取区域,再 reduce_domain,然后跑模板匹配或阈值分割。关键是在检测线程里不要访问控件的 UI 对象,而是传一个只读的 ROI 配置副本。配置可以在“运行时更新”和“检测线程使用”之间做双缓冲,避免一边拉 ROI 一边检测造成的不一致。我在实际项目中就把这个控件嵌入到配方管理界面,每个产品型号对应一份 JSON 配置,切换型号时自动加载对应 ROI 列表,检测程序完全不用改代码,只改配置。
回头看我被 ROI 控件折磨的那几次,最大的教训就是把坐标映射和资源释放做好,剩下的交互细节都是时间问题。第一次做的时候图简单,直接在 MouseMove 里调用 Halcon 绘图算子,结果线宽和颜色被频繁重置,预览效果惨不忍睹。后来改用纯 C# 维护坐标、只在杠杆点变化时才构造 HRegion,整个控件的稳定性才真正提上来。如果你也准备写这个控件,建议从最小可用版本开始:先能画一个矩形并保存,再去扩展圆和多边形,每加一个形状就把它的命中测试和手柄逻辑写清楚。希望这份笔记能帮到你少走这几步弯路。
本文还有配套的精品资源,点击获取