
做WPF上位机项目时我接过一个需求在界面里加载一张大图用户要用鼠标拖出一个矩形区域拖动过程中能实时看到坐标拖完还要能继续调整大小。第一反应是在WPF里画一个Rectangle控件然后用MouseDown、MouseMove、MouseUp三个事件硬扣拖动逻辑再把控件坐标换算成图像像素坐标。等真的动手才发现光是坐标换算就够喝一壶——WPF单位、像素、DPI、图像缩放、窗口滚动条每个环节都会让ROI偏移几个像素特别是图像被缩放显示后矩形框和实际框选区域对不上排查半天也找不到原因。后来换成Halcon 20.11自带的交互式绘图能力整个ROI的创建、拖动、坐标获取直接从几十行自绘代码压缩到几行核心调用。Halcon的HDrawingObject对象内置了绘制、拖动、缩放的整套交互逻辑还支持回调事件在回调里实时取坐标、刷新UI工程上稳定得多。这篇文章就结合我的实际项目把Halcon 20.11在WPF中实现矩形ROI创建、拖动与坐标获取的完整思路和代码拆开讲包括环境搭建、控件选型、回调机制、坐标验证和实测踩坑适合正在做WPF上位机且需要集成视觉ROI交互的开发者参考。1. 为什么我把ROI交互从自绘Canvas换成了Halcon内置方案1.1 自绘矩形的坐标换算坑先说说我在自绘方案里踩过的具体问题这样你就能理解为什么后来坚持用Halcon的交互对象。WPF的坐标系是逻辑单位DIP1个逻辑单位在96DPI下等于1物理像素但在125%、150%缩放的系统里就被放大了。Halcon的HWindowControl显示图像时内部又会根据控件尺寸和图像尺寸做一次缩放适配。如果你在控件上叠加一个WPF Rectangle它使用的是WPF控件的本地坐标系你通过鼠标事件拿到的e.GetPosition(rect)坐标也是相对控件的逻辑坐标。要把这个坐标换算成Halcon图像的行列坐标你需要先知道当前图像的显示缩放比double scaleX imageWidth / (double)hSmartWindow.ActualWidth; double scaleY imageHeight / (double)hSmartWindow.ActualHeight; // 再考虑图像居中的偏移量 double imageX (mousePos.X - offsetX) * scaleX; double imageY (mousePos.Y - offsetY) * scaleY;听起来简单但一旦图像是等比缩放黑边模式下或者窗口可以自由拉伸这个offset和scale会动态变化鼠标拖动时稍有不稳矩形框就“飘”了。更麻烦的是自绘矩形还要自己处理命中测试——用户拖角点、拖边线、整体拖动是三种不同逻辑再加上最小尺寸限制、越界纠正代码量很快就失控了。1.2 HDrawingObject的定位它是“带回调的绘制对象”不是一次性模态框Halcon里做ROI交互其实有两套思路。一套是用draw_rectangle1、draw_rectangle2这类阻塞式交互算子用户画一次就结束程序拿到坐标继续往下走。这种方式做“画一次”的需求没问题但如果你想“画完还能继续拖动微调”它就不合适了——draw_rectangle1是一次性的画完就释放交互控制器了。另一套就是我推荐的create_drawing_object_rectangle1创建出来的HDrawingObject是挂在窗口上的常驻绘制对象。它自带完整的交互能力默认状态下鼠标可以拖动它整体移动拖到角点会出现拉伸手柄改变矩形尺寸拖到边界会出现移动光标调整矩形位置。所有这些交互都不需要你写任何鼠标事件代码Halcon内部已经把命中测试、光标样式、手柄逻辑全部处理完了。你要做的只是通过回调事件在适当的时候读取它的最新坐标。这套机制还有一个明显的工程优势HDrawingObject返回的坐标永远是图像坐标不需要你做任何换算。无论图像被放大、缩小、平移你拿到的row1、column1、row2、column2都是图像像素坐标系里的真实值直接就可以喂给后续的reduce_domain、template matching、measure_pos等算子。2. 环境与控件选型Halcon 20.11接入WPF的两种姿势2.1 引用halcondotnet.dll和运行时环境在WPF里用Halcon第一步是正确引用.NET程序集。Halcon 20.11安装完成后在安装目录下会有halcondotnet.dll它同时提供了WinForms控件和WPF控件。我项目里的路径类似C:\Program Files\MVTec\HALCON-20.11\bin\dotnet35\halcondotnet.dll C:\Program Files\MVTec\HALCON-20.11\bin\dotnet45\halcondotnet.dll你的WPF项目如果是.NET Framework 4.6.2或4.8直接引用dotnet45目录下的这个dll即可。右键项目引用添加引用浏览到dll位置确定。如果你想用VS工具箱拖控件可以在“选择工具箱项”里勾选Halcon相关的控件名称之后XAML设计器就能直接托入。运行时还需要Halcon的原生DLL比如halcon.dll、halconcpp.dll、hdevenginecpp.dll这些。如果安装时选择了加入系统PATH环境变量通常没问题如果没加程序启动时会报“无法加载DLL halcon.dll”这样的错。我的做法是安装后手动检查一下HALCONROOT环境变量是否指向安装目录同时把bin\x64-win64加到PATH里。注意如果是.NET 5/6/8的WPF项目Halcon 20.11虽然提供了.NET Standard 2.0版本的dll但实际兼容性还是需要自己验证的。我在正式项目里用的是.NET Framework 4.8踩坑最少。建议新项目在不确定的情况下先做一个小Demo验证再决定整体框架。2.2 WinForms版HWindowControl与WPF版HSmartWindowControlWPFHalcon 20.11提供了两个窗口控件控件类技术栈使用方式问题HSmartWindowControlWinForms必须放在WindowsFormsHost里WinForms控件层级高会盖住WPF元素HSmartWindowControlWPFWPF原生直接放XAML兼容性较好推荐很多老教程用的是HSmartWindowControl因为早期版本对WPF支持不好。但20.11已经完全支持HSmartWindowControlWPF没必要再套一层WindowsFormsHost。不过要注意HSmartWindowControlWPF在内部依然借助了WindowsFormsHost机制所以它不能和WPF的手写控件做“透明叠加”比如你设想在它上方放一个半透明的Border是显示不出来的。但在我们的ROI需求里不需要这种叠加坐标通过回调刷新到界面旁边的TextBlock里就行。2.3 一个XAML托管示例我项目的XAML布局大概长这样。HSmartWindowControlWPF的命名空间引用方式如下Window x:ClassHalconRoiDemo.MainWindow xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml xmlns:halconclr-namespace:HalconDotNet;assemblyhalcondotnet TitleHalcon 20.11 WPF交互式ROI Demo Height720 Width1080 DockPanel StackPanel DockPanel.DockTop OrientationHorizontal Margin8 Button Content加载图像 ClickLoadImage_Click Width90/ Button Content创建矩形ROI ClickCreateRoi_Click Width110 Margin8,0,0,0/ Button Content清除ROI ClickClearRoi_Click Width90 Margin8,0,0,0/ Button Content应用ROI并统计 ClickApplyRoi_Click Width120 Margin20,0,0,0/ /StackPanel StackPanel DockPanel.DockBottom Margin8,4,8,8 OrientationHorizontal TextBlock Text左上: VerticalAlignmentCenter/ TextBlock x:NameTxtLeftTop Text-- Width130 VerticalAlignmentCenter/ TextBlock Text右下: VerticalAlignmentCenter/ TextBlock x:NameTxtRightBottom Text-- Width130 VerticalAlignmentCenter/ TextBlock Text中心: VerticalAlignmentCenter/ TextBlock x:NameTxtCenter Text-- Width170 VerticalAlignmentCenter/ TextBlock Text宽高: VerticalAlignmentCenter/ TextBlock x:NameTxtSize Text-- Width120 VerticalAlignmentCenter/ /StackPanel Border Margin8 BorderBrush#999 BorderThickness1 halcon:HSmartWindowControlWPF x:NamehSmartWindow / /Border /DockPanel /WindowHSmartWindowControlWPF有一个HalconWindow属性类型是HWindow这就是你后续调用所有显示算子的窗口句柄。加载图像时用SetPart设置图像显示范围再用DispObj显示。3. 核心实现创建、拖动、回调一条龙的矩形ROI3.1 图像显示与坐标系基准在创建ROI之前先要把图像显示到一个确定的状态。如果图像显示范围不固定比如图像缩放比例一直在变虽然HDrawingObject依然返回图像坐标但用户在视觉上会感觉矩形对不上图像体验很怪。我的做法是在加载图像后固定显示整张图private HImage _image; private HDrawingObject _roiDrawingObject; private void LoadImage_Click(object sender, RoutedEventArgs e) { OpenFileDialog dlg new OpenFileDialog(); dlg.Filter 图像文件|*.png;*.bmp;*.jpg;*.jpeg;*.tif;*.tiff|所有文件|*.*; if (dlg.ShowDialog() ! true) return; _image?.Dispose(); _image new HImage(dlg.FileName); hSmartWindow.HalconWindow.ClearWindow(); // 把显示范围设为整幅图像坐标基准就是图像行列坐标系 hSmartWindow.HalconWindow.SetPart(0, 0, _image.Height - 1, _image.Width - 1); hSmartWindow.HalconWindow.DispObj(_image); }SetPart的前两个参数是显示区域左上角的行列坐标后两个是右下角的行列坐标。这里设置成整图范围后后续所有绘制对象和区域都在图像坐标系下工作这是个非常重要的基准。3.2 创建矩形ROI的两种API及差异Halcon的矩形有两个典型类型参数rectangle1rectangle2数学定义轴对齐矩形带旋转角度的矩形参数row1, column1, row2, column2row, column, phi, length1, length2适用场景测量、模板匹配搜索区域倾斜目标、带角度的ROIrectangle1是轴对齐矩形参数是两个对角点rectangle2是带旋转角度的矩形中心点加旋转角加长半轴和短半轴。在做尺寸测量、缺陷定位时rectangle2更常用因为很多目标在图像里是倾斜的。但为了让交互最直接我的Demo先用rectangle1逻辑最简单坐标也最容易理解。在C#里创建HDrawingObject我用的方法是private void CreateRoi_Click(object sender, RoutedEventArgs e) { if (_image null) { MessageBox.Show(请先加载图像); return; } // 重复创建前先清理旧对象 DisposeRoi(); int imgH _image.Height; int imgW _image.Width; // 在图像中心区域创建一个初始矩形 HTuple row1 imgH / 4; HTuple col1 imgW / 4; HTuple row2 imgH * 3 / 4; HTuple col2 imgW * 3 / 4; HDrawingObject roi null; try { roi new HDrawingObject(); roi.CreateDrawingObjectRectangle1(row1, col1, row2, col2); roi.SetDrawingObjectCallback(OnDrawingObjectCallback); roi.SetDrawingObjectParams(color, green); roi.SetDrawingObjectParams(line_width, 2); roi.AttachDrawingObjectToWindow(hSmartWindow.HalconWindow); _roiDrawingObject roi; // 创建后立即刷新一次坐标 RefreshRoiCoordinate(roi); } catch (Exception ex) { MessageBox.Show(创建ROI失败 ex.Message); roi?.Dispose(); } }如果你在IDE里发现CreateDrawingObjectRectangle1方法名不对可以改用构造函数带类型参数的形式HDrawingObject roi new HDrawingObject(HDrawingObject.HDrawingObjectType.RECTANGLE1, row1, col1, row2, col2);两种方式本质上都是调用Halcon的create_drawing_object_rectangle1算子看你项目里哪个写法编译更顺手。3.3 完整代码从创建到拖动回调SetDrawingObjectCallback是整段逻辑的核心。Halcon在拖动、调整大小、属性变化等时机都会触发这个回调。我在回调里做的事只有三件从ROI对象上读取最新坐标把坐标刷新到WPF的TextBlock里在图像上重绘中心十字和坐标文本给用户视觉反馈。回调方法签名是固定的private void OnDrawingObjectCallback(IntPtr drawObject, IntPtr window, IntPtr objContext) { // 用户可能已经点击了“清除ROI”此时回调可能还在队列中必须先判空 if (_roiDrawingObject null) return; // 回调线程不一定是UI线程通过Dispatcher切到UI线程再更新控件 Dispatcher.BeginInvoke(new Action(() { RefreshRoiCoordinate(_roiDrawingObject); DrawCenterCross(); })); } private void RefreshRoiCoordinate(HDrawingObject roi) { HTuple row1 roi.GetDrawingObjectParams(row1); HTuple col1 roi.GetDrawingObjectParams(column1); HTuple row2 roi.GetDrawingObjectParams(row2); HTuple col2 roi.GetDrawingObjectParams(column2); double r1 row1.D; double c1 col1.D; double r2 row2.D; double c2 col2.D; TxtLeftTop.Text $({r1:F1}, {c1:F1}); TxtRightBottom.Text $({r2:F1}, {c2:F1}); double centerR (r1 r2) / 2.0; double centerC (c1 c2) / 2.0; TxtCenter.Text $({centerR:F1}, {centerC:F1}); double width Math.Abs(c2 - c1) 1; double height Math.Abs(r2 - r1) 1; TxtSize.Text ${width:F0} x {height:F0}; } private void DrawCenterCross() { if (_image null || _roiDrawingObject null) return; HTuple row1 _roiDrawingObject.GetDrawingObjectParams(row1); HTuple col1 _roiDrawingObject.GetDrawingObjectParams(column1); HTuple row2 _roiDrawingObject.GetDrawingObjectParams(row2); HTuple col2 _roiDrawingObject.GetDrawingObjectParams(column2); double centerR (row1.D row2.D) / 2.0; double centerC (col1.D col2.D) / 2.0; hSmartWindow.HalconWindow.DispObj(_image); hSmartWindow.HalconWindow.SetColor(yellow); hSmartWindow.HalconWindow.DispCross(centerR, centerC, 20, 0); hSmartWindow.HalconWindow.SetTpos(centerR - 30, centerC 12); hSmartWindow.HalconWindow.WriteString($({centerR:F1}, {centerC:F1})); hSmartWindow.HalconWindow.SetColor(green); }这段代码有两个细节要展开说。第一个细节为什么DrawCenterCross里要先DispObj(_image)重新显示图像因为DispCross和WriteString画上去的内容下一次再画的时候不会自动消失如果不重绘图像十字和文字会残留成一条条影子。先重绘图像再画新十字画面就干净了。代价是拖动时会有轻微闪烁尤其在性能一般的机器上。如果你的场景对闪烁敏感可以在回调里减少重绘次数只保留坐标文本的刷新不画十字视觉负担会小很多。第二个细节为什么文字写的坐标是行列而不是X/Y因为Halcon的图像坐标系以左上角为原点行向下增加列向右增加。后续所有图像处理算子返回的坐标都是这个行列体系不是数学里常见的平面直角坐标系。上位机界面如果要显示给操作员看建议在界面上明确标注“行/列”或“Row/Column”否则很容易和数学坐标搞混。3.4 清除ROI的完整姿势清除ROI不只是从窗口移除绘制对象还要把引用置空防止回调继续访问已经释放的对象。private void ClearRoi_Click(object sender, RoutedEventArgs e) { DisposeRoi(); TxtLeftTop.Text --; TxtRightBottom.Text --; TxtCenter.Text --; TxtSize.Text --; if (_image ! null) { hSmartWindow.HalconWindow.DispObj(_image); } } private void DisposeRoi() { if (_roiDrawingObject ! null) { _roiDrawingObject?.Dispose(); _roiDrawingObject null; } }有一点要提醒如果你调用了DisposeRoi之后之前用Dispatcher.BeginInvoke排队的回调还没有执行那回调里读取_roiDrawingObject时会得到null所以回调方法开头的判空必须写。4. 坐标获取与ROI落地验证4.1 从矩形参数到中心点、宽高ROI画出来只是第一步工程里真正要的是坐标数据。对rectangle1来说从四个参数算中心点、宽高非常简单。宽高为什么要加1因为图像像素坐标是离散的从行列100到200实际的像素数量是101个。不同的测量算子对边界像素的处理方式不完全一样有些算子内部会做类似width col2 - col1 1的修正。在编写测量逻辑时要和Halcon算子的实际行为对齐否则可能差1个像素。做高精度测量时这个“像素差”会直接影响结果。如果你的后续流程用的是rectangle2那获取的参数就变成了row、column、phi、length1、length2。中心点就是row和column不需要再算length1和length2是半轴长度实际宽度是2倍的length2实际高度是2倍的length1。4.2 用十字与文字在图像上直接标注中心坐标数据在界面里显示是一回事但下一次视觉处理时我想把中心点也叠加到图像上供肉眼确认。这就用到DispCross和WriteString。DispCross的第一个参数是窗口句柄第二个是行坐标第三个是列坐标第四个是十字半径第五个是旋转角度0表示不旋转。我在前面代码里已经用了这个组合先用黄色画十字和文字再切回绿色。这样用户拖动ROI时能直观看到中心点实时跟随同时界面下方的坐标文本也在同步变化。如果拖动过程觉得十字闪烁太明显有一个折中方案不要每次回调都重绘而是在ROI拖动结束的那一帧重绘。但怎么知道拖动结束Halcon的回调类型里可以区分on_drag、on_resize、on_attach等。你可以给SetDrawingObjectCallback传不同的回调函数分别处理不同阶段。我在项目里是让on_drag只管刷新TextBlock不做图像重绘on_resize和on_attach才重绘十字。这样拖动过程中十字不闪只在松手后稳定更新。代码结构也很简单roi.SetDrawingObjectCallback(OnDrawingObjectMoving, on_drag); roi.SetDrawingObjectCallback(OnDrawingObjectChanged, on_resize);这里第二个参数指的是Halcon的callback type不同版本的定义可能有差异实际以你正在用的版本联机帮助为准。4.3 ReduceDomain把ROI变成真正可用的子图像坐标能不能用最直接的验证方式就是拿ROI去做一次实际图像处理。Halcon里最标准的一套组合是GetDrawingObjectRegion得到ROI的Region再用ReduceDomain裁剪出ROI区域内的子图像后续做灰度统计、模板匹配、字符识别都基于这个子图。private void ApplyRoi_Click(object sender, RoutedEventArgs e) { if (_image null || _roiDrawingObject null) { MessageBox.Show(请先加载图像并创建ROI); return; } HRegion roiRegion null; HImage roiImage null; try { // 从绘制对象上拿到区域这个区域就是在图像坐标系下的 roiRegion _roiDrawingObject.GetDrawingObjectRegion(); // 用区域裁剪图像 roiImage _image.ReduceDomain(roiRegion); HTuple mean, deviation; HOperatorSet.Intensity(roiRegion, roiImage, out mean, out deviation); MessageBox.Show($ROI区域灰度均值{mean.D:F2}标准差{deviation.D:F2}); } finally { roiImage?.Dispose(); roiRegion?.Dispose(); } }这段代码跑通了就证明坐标获取全链路是可靠的用户拖动ROI的坐标已经准确作用到了图像上而不是只停留在界面数字上。实际项目里这段逻辑的替换空间很大。比如你要做模板匹配就把mean和deviation换成CreateShapeModel的输入区域你要做尺寸测量就用ROI区域配合measure_pos只关注这个矩形内的边缘。本质上都是同一个思路ROI确定了“看哪里”后续算子决定“怎么看”。5. 实战踩坑与工程化建议5.1 回调线程与Dispatcher别在回调里直接碰WPF控件这是我最想提醒的一条。Halcon的drawing object回调并不保证运行在WPF的UI线程上而WPF控件只允许STA线程操作。如果你在回调里直接写TxtLeftTop.Text ...轻则偶发跨线程异常重则界面直接卡死或闪退。解决方案就是前面代码里的Dispatcher.BeginInvoke。要注意的是BeginInvoke是异步的回调执行到一半的时候UI线程可能还没来得及更新文本于是你会看到坐标刷新有轻微延迟。这个延迟在拖动场景下通常无所谓人眼感知不到。只有当ROI对象在UI线程被释放时才可能出现问题所以回调里一定要加null判断。5.2 对象释放与内存泄漏HObject、HDrawingObject都要DisposeHalcon的.NET封装对象比如HImage、HRegion、HDrawingObject很多都直接包装了原生内存。如果只把引用置null而忘记Dispose内存不会被完全回收。在连续图像处理的场景下十几个画面后内存就会明显上涨最终OOM。我个人的习惯是凡是new HImage()或new HDrawingObject()出来的对象要么用using包裹要么在finally里手动Dispose。ROI对象因为是窗体级的全局变量在替换和关闭窗口时都要清理。窗口关闭事件里也别忘了protected override void OnClosed(EventArgs e) { DisposeRoi(); _image?.Dispose(); base.OnClosed(e); }5.3 多ROI场景如何区分drawObject我的Demo只维护了一个ROI但实际生产里经常要画多个区域一个搜索区域、一个检测区域、一个字符识别区域互相独立。这时候回调方法里的drawObject参数就派上用场了。Halcon的C#回调签名是void Callback(IntPtr drawObject, IntPtr window, IntPtr objContext)其中drawObject可以理解为这个绘制对象的标识。如果你要区分多个ROI可以在创建时给每个ROI一个业务ID并存进字典private DictionaryIntPtr, string _roiIdMap new DictionaryIntPtr, string();但问题在于创建HDrawingObject时你拿不到它的IntPtr编号只有在回调里才拿得到。所以一种可行的实践是在第一次回调触发时以drawObject为key注册同时结合objContext参数做区分。如果不想过度设计也可以简单点给每个ROI设置不同的颜色或不同的回调方法roiA.SetDrawingObjectCallback(OnRoiACallback); roiB.SetDrawingObjectCallback(OnRoiBCallback);这种方法最直观也好维护。5.4 高DPI、窗口缩放与拖动偏移问题在4K屏、150%缩放的工控机上HSmartWindowControlWPF偶尔会出现鼠标拖动ROI时矩形框和光标位置偏移的情况。这个问题多半不是Halcon本身的问题而是宿主窗口的DPI感知设置不对。解决方法是在MainWindow构造函数或App.xaml.cs启动最前面调用SetProcessDpiAwareness或声明DPI感知或者使用.NET Framework 4.8自带的app.config中添加DpiAwarenessPerMonitorV2/DpiAwareness声明。遇到这类问题先确认系统缩放比例再检查进程是否被标记为DPI感知。很多时候界面看着发虚但ROI坐标是准的那就是DPI感知没配好。5.5 WindowsFormsHost带来的层级问题如果避不开怎么办前面我推荐用HSmartWindowControlWPF但如果你的项目历史原因已经在用HSmartWindowControlWinForms版本并且套了WindowsFormsHost有一个坑要知道WindowsFormsHost是一个窗口句柄隔离的宿主它会浮在大部分WPF元素之上。不要在它的上层放需要显示的WPF控件否则会被盖住。解决方法是把需要交互的WPF元素放在WindowsFormsHost之外用布局隔离而不是叠在同一块区域。另外WindowsFormsHost还有一个常见的焦点问题点击它之后WPF侧的键盘事件可能失效。处理方式是给WindowsFormsHost设置Focusabletrue或者在点击后手动调用Focus()转移焦点。5.6 保存与恢复ROI参数最后分享一个项目中的实用习惯设备重启后ROI位置通常是固定的每次让操作员重新画一遍不现实。我建议把ROI参数序列化保存到配置文件里string roiConfig ${row1.D};{col1.D};{row2.D};{col2.D}; File.WriteAllText(roi.txt, roiConfig);下次启动时读出来直接调用CreateDrawingObjectRectangle1把ROI恢复到窗口上。这样既保留了交互能力又免去了操作员重复劳动。坐标保存的标准统一用图像坐标不要存界面坐标因为下次加载的图像尺寸或显示缩放可能和上次不一样界面坐标毫无可移植性。在我实际做的几个测量项目里这套方案的维护成本非常低。第一次做的时候回调线程和对象释放是我花时间最多的地方一旦把这两个点想清楚后续新增圆形ROI、椭圆ROI、旋转矩形ROI都只是换一个创建方法和几个参数的问题交互逻辑完全复用。如果你在WPF集成Halcon时卡在了ROI交互上希望这篇能把你的弯路省下来。