免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

WPF自绘ROI交互控件:完美复刻Halcon操作体验

WPF自绘ROI交互控件:完美复刻Halcon操作体验 简介一套基于WPF的C#图像显示与ROI管理控件面向工业检测、医学影像、教学演示等需要轻量级标注能力的桌面开发场景可在不依赖Halcon运行时的情况下复现HSmartWindowControl的核心交互体验。控件支持图像加载、缩放、平移以及矩形、圆形、多边形、椭圆等常见ROI的鼠标实时绘制、顶点拖拽调整、选中高亮、键盘删除和批量导出坐标数据内部采用CanvasShape组合渲染ROI数据结构独立封装便于与OpenCV或EmguCV图像处理流程对接。压缩包共52个文件以26个cs源码、3个xaml界面、3个csproj工程文件为主另含主题样式、配置与Avalonia跨平台版本整体仅106KB。已有32人学习可直接运行TestWindowWpf示例参考RoiImplementation模块快速集成适合需要自定义图像标注控件的开发者。1. 为什么要自绘一个 ROI 控件从 Halcon 交互习惯说起做工业视觉上位机开发的人应该都有过这种体会算法本事再大操作界面不顺手现场调试人员照样会把你辛苦做的工具喷成“不好用”。尤其是从 Halcon 切到 WPF 技术栈之后经常遇到一个尴尬事——Halcon 窗口里画 ROI、拖 ROI、删 ROI 那套交互逻辑太成熟了鼠标点下去就完事。而 WPF 自带的Image控件只能显示图片想在图片上画一个可调整的矩形、圆或旋转矩形整套交互都得从头写。这个项目最初的需求并不复杂做一个图像显示控件支持鼠标绘制、拖拽、删除 ROI 区域并且交互逻辑要贴近 Halcon 的风格。为什么强调 Halcon 风格因为现场工艺工程师和产线调试人员已经习惯了 Halcon 的交互逻辑让他们换个工具还要重新学一遍点选、拖动的习惯显然不现实。最终我把这套控件做成了纯 WPF 实现的自定义图像显示控件底层不依赖 Halcon 的 dll部署到没有安装 Halcon 的电脑上也能运行。本文就把关键设计思路、实现步骤和踩过的坑完整梳理一遍给同样在 WPF 里做视觉上位机交互的朋友一个可以直接参考的方案。1.1 先搞清楚Halcon 的 ROI 交互到底“顺手”在哪里很多新人会把 ROI 交互简单理解成“画个框”但真正在现场用过的才知道ROI 交互最难的不是画而是选中、平移、缩放边界、删除这一整套连续操作。Halcon 的交互窗口里你按下鼠标左键拖动可以画出一个矩形画完之后 ROI 自动处于选中状态边框颜色会变化单击 ROI 内部任意位置就能整体拖动边框上出现手柄拖动手柄可以调整宽高按右键或 Delete 键可以删除 ROI。这些动作看似简单但每一条都直接影响鼠标控制精度和操作效率。在视觉检测上位机里ROI 通常有几个典型使用场景模板匹配前用 ROI 限定搜索区域缺陷检测时用 ROI 屏蔽掉不需要检测的干扰区域测量工具里用旋转矩形框住待测对象的边缘。你想想如果画一个 ROI 之后必须去属性框里输入坐标才能改位置测量几十个点位的时候操作工能疯掉。所以我在着手写这个控件时第一个决定就是交互手感可以简化但核心的“绘制-选中-拖动-缩放手柄-删除”闭环必须完整保留下来。这是整个控件能不能被现场接受的底线。1.2 为什么不直接嵌入 Halcon 自带的 HWindowControl我知道很多项目一开始会优先考虑嵌入 Halcon 的HWindowControl因为 ROI 功能是现成的不用重新造轮子。这个方案我在项目前期也认真评估过最后否掉了原因有几点第一WPF 和 WinForms 控件混用时有 Airspace 问题HWindowControl本质上还是 WinForms 窗口它可以显示图像但想在它上面叠加 WPF 元素、弹出 WPF 的右键菜单、或者做动画遮罩会出现置顶覆盖不了的麻烦这在现代上位机界面里非常难受。第二部署问题。使用 Halcon 控件意味着用户电脑上必须安装 Halcon runtime 环境并且需要授权。很多时候客户现场的环境非常干净不允许安装额外运行库或者授权成本高我们希望一个上位机程序拷贝过去就能运行。第三这个项目的核心并不是图像算法本身而是 ROI 区域标注和交互算法后端以后可能是 OpenCV、Halcon 或其他库替换。如果 UI 层和 Halcon 绑定太死后续技术栈调整会非常痛苦。所以结论很明确用 WPF 自己画一个轻量级显示控件ROI 的绘制和交互完全自研。这样 UI 和算法解耦部署无依赖后续接什么算法库都不影响界面层。2. 控件整体架构把图像渲染、ROI 数据和鼠标交互拆开这种控件如果一上来就疯狂往MouseLeftButtonDown里写逻辑代码很快会变成一团乱麻。我的经验是先把职责分清楚至少分三层视觉渲染层负责画图像和 ROI数据层负责存储 ROI 的几何信息交互层负责把鼠标动作翻译成对数据层的修改。数据层和渲染层分离的好处特别明显当你需要保存 ROI 列表到工程文件、撤销重做、或者把 ROI 同步给算法模块时直接读取数据模型就行完全不用关心界面怎么画。反过来如果 ROI 数据内容没变化只是视觉需要重绘也不会误修改数据。整体结构大致是这样的RoiDisplayControl继承自FrameworkElement的显示控件承载所有视觉绘制。RoiBaseROI 数据模型抽象基类包含几何判断、平移、绘制等能力。RoiInteraction鼠标交互控制器维护当前绘制/拖拽/缩放的状态。RoiCollection当前图像上所有 ROI 的集合对外通知增删改。接下来重点说两个让我反复纠结过的设计点。2.1 DrawingVisual 还是 Canvas两种自绘方式的取舍WPF 里实现这种自绘控件最常用的两种方式是Canvas加Shape对象或者DrawingVisual自绘。我做了一个简单对比方案优点缺点适用场景Canvas Shape代码直观支持模板事件绑定方便每个 ROI 都是一个 UIElement对象多时布局和命中测试开销大ROI 数量少开发周期紧DrawingVisual 自绘渲染性能高没有布局系统干扰适合高频刷新需要手动管理 VisualCollection代码复杂度高图像大、ROI 多、需要流畅拖拽我最终选择DrawingVisual。因为在图像显示控件里鼠标拖动 ROI 时界面要高频刷新如果 ROI 数量达到几十上百个还在用Canvas每个矩形挂一个Shape拖拽时所有元素的布局、渲染都会被波及性能下降非常快。而DrawingVisual不参与 WPF 的布局系统绘制全部走DrawingContext性能要干净利落得多。使用方式如下自定义一个继承FrameworkElement的控件内部维护VisualCollection每个 ROI 对应一个DrawingVisual。public class RoiDisplayControl : FrameworkElement { private readonly VisualCollection _visuals; public RoiDisplayControl() { _visuals new VisualCollection(this); Background Brushes.Transparent; } protected override int VisualChildrenCount _visuals.Count; protected override Visual GetVisualChild(int index) { return _visuals[index]; } private int AddVisual(DrawingVisual visual) { _visuals.Add(visual); return _visuals.Count - 1; } }这里有个小坑必须提醒如果这个控件显示在Grid里但没设置Background那么点击空白区域时控件不会收到鼠标事件。我刚开始就吃过这个亏界面上怎么点都没反应后来给FrameworkElement设置了Background Brushes.Transparent才正常。2.2 ROI 数据模型矩形、圆、旋转矩形怎么统一抽象ROI 类型通常有矩形、旋转矩形、圆、椭圆几种。如果为每种类型单独写一套逻辑后续维护会非常痛苦。我设计的做法是定义一个抽象基类把公共能力全部收进去public abstract class RoiBase { public int Id { get; set; } public bool IsSelected { get; set; } public RoiType Type { get; protected set; } public abstract bool Contains(Point imagePoint, double tolerance); public abstract void Move(Vector offset); public abstract void Resize(double deltaX, double deltaY); public abstract void Draw(DrawingContext dc, Point origin, double scale); public abstract RoiOutputData ToOutputData(); }这里最关键的一点是ROI 内部存储的坐标一律使用图像像素坐标而不是控件坐标。比如一个矩形 ROI我存储的是中心点(CenterX, CenterY)、宽度SizeX、高度SizeY。所有鼠标操作先通过坐标换算变成图像坐标再交给 ROI 数据模型处理。这样图像缩放、平移、拉伸窗口时ROI 对应的真实图像位置不会变输出给算法时也不需要额外纠正。这个决定一开始看起来会多写不少换算代码但它才是整个控件稳定性的基石。我见过很多半成品控件ROI 坐标直接用控件坐标存图像窗口一缩放 ROI 就飘走就是因为没有处理好数据坐标系和屏幕坐标系的分离。3. 鼠标绘制与拖拽修改的核心实现细节交互逻辑是整个控件最见功夫的地方。Halcon 风格的交互有个显著特点状态切换非常自然鼠标按下去、拖动、弹起来每一步用户都能看到实时反馈。实现上就需要一个清晰的状态机。我先定义鼠标动作枚举public enum MouseAction { None, DrawingRect, Moving, Resizing }然后在鼠标事件里根据当前状态决定行为。用状态机的好处是代码逻辑清晰不会出现“画到一半又去拖动”的混乱情况。3.1 绘制状态机按下、移动、抬起的完整流程在MouseLeftButtonDown里按下时按顺序做三类判断第一先判断鼠标是否落在某个 ROI 的手柄区域内。如果命中则进入Resizing状态记录当前手柄类型和初始参数。这一步必须在移动判断之前手柄的优先级最高。第二判断鼠标是否落在某个 ROI 内部。如果命中则选中该 ROI并进入Moving状态记录按下位置作为移动基准点。第三如果当前工具栏处于“绘制矩形”模式并且鼠标落在空白区域则创建一个新的矩形 ROI进入DrawingRect状态。在MouseMove里根据状态分别处理if (_action MouseAction.DrawingRect) { Point imagePoint ScreenToImage(e.GetPosition(this)); _currentRoi.ResizeTo(imagePoint); RefreshRoiVisual(_currentRoi); } else if (_action MouseAction.Moving) { Point currentImage ScreenToImage(e.GetPosition(this)); Vector offset currentImage - _downImagePoint; _currentRoi.Move(offset); _downImagePoint currentImage; RefreshRoiVisual(_currentRoi); }注意移动操作里的_downImagePoint currentImage这一行。如果不把当前点更新为新的基准点每次移动都会把累积偏移重复叠加ROI 会越拖越偏。这个细节掉过好几次坑后来记到代码注释里才没再犯。在MouseLeftButtonUp里结束当前动作如果是在绘制状态下创建的 ROI就把它正式加入集合并触发RoiAdded事件。另外还要处理Esc键取消绘制、Delete键删除选中 ROI这虽然不是鼠标事件但也是交互闭环的一部分。3.2 命中测试与八方向手柄拖拽WPF 里做命中测试可以借助VisualTreeHelper.HitTest但在这个场景里我反而更喜欢手动判断因为 ROI 数量不会特别多而且手动判断能精确控制容差。所谓八方向手柄就是选中 ROI 后在矩形边框的四个角和四条边中点画上小方块。拖动不同手柄效果不同角上的手柄同时改变宽高边上的手柄只改变对应方向。我定义了一个枚举private enum HitHandleType { None, TopLeft, Top, TopRight, Left, Right, BottomLeft, Bottom, BottomRight }命中测试逻辑其实就是一个Rect判断。把鼠标点在图像坐标系里的位置和 ROI 的实际边框做比较差距小于 5 个像素就算命中。代码大致如下private HitHandleType HitTestHandle(Point testPoint, Rect roiRect) { const double tolerance 5; bool nearLeft Math.Abs(testPoint.X - roiRect.Left) tolerance; bool nearRight Math.Abs(testPoint.X - roiRect.Right) tolerance; bool nearTop Math.Abs(testPoint.Y - roiRect.Top) tolerance; bool nearBottom Math.Abs(testPoint.Y - roiRect.Bottom) tolerance; // 依次组合判断返回对应手柄类型 }拖动手柄时ROI 的最小宽高建议做限制。否则用户稍微拖过头矩形就可能变成一条线或者反向视觉上很怪异。我一般限制最小宽高不小于 5 个像素。3.3 删除交互快捷键、右键菜单和工具栏按钮删除 ROI 最符合 Halcon 习惯的操作是右键菜单。我在控件里把右键事件接出来显示一个精简的上下文菜单删除当前 ROI删除全部 ROI如果支持撤销还可以放一个撤销操作同时监听KeyDown事件当Delete键按下时删除当前选中的 ROI。这看起来很基础但在实际使用中非常高频现场人员画错了之后习惯性按 Delete如果控件没响应体验立刻降级。由于项目使用了 MVVM控件的删除操作不应直接调用 ViewModel 的方法而是通过事件向外通知。控件只负责把“哪个 ROI 被删了”这件事通报出去由外部订阅者决定是更新界面、更新数据库还是发送给算法线程。RoiDeleted事件里带上被删除 ROI 的 Id 就足够了。4. 兼容 Halcon 交互逻辑的几个关键细节很多人在网上搜“WPF ROI 控件”能找到一堆 Demo但 Demo 和真正能用的控件之间差距往往不在功能而在细节。用户默认你会操作你做对了他们不说话做错了立刻就会觉得“不对味”。4.1 颜色反馈和选中态Halcon 用户的肌肉记忆Halcon 窗口默认的 ROI 颜色是绿色选中后使用红色高亮。我沿用这个配色没有自己做创新未选中的 ROI绿色边框线宽 1。选中的 ROI红色边框线宽 2另外在矩形四角显示 8 个方块手柄。正在绘制中的 ROI黄色半透明边框内部用半透明黄色填充表示还没有落定。这个细节很小但效果立竿见影。现场人员看到绿色就知道“这是已经存在的区域”看到红色就知道“这是我接下来要操作的”完全不用额外学习。颜色定义放在资源里方便后续按公司 UI 规范调整。4.2 坐标换算的完整推导不靠 Stretch自己管缩放图像显示控件里最大的隐性 bug 来源就是坐标换算。如果直接使用Image控件的StretchUniform属性图像确实能居中缩放但当你想把鼠标坐标转换成图像坐标时必须自己去算实际的绘制区域。我最终选择完全不用Image控件而是在自定义控件里通过DrawingContext.DrawImage绘制图像。这样整个坐标映射逻辑完全在我的掌控之下。假设控件可视区域是viewWidth x viewHeight图像原始尺寸是imageWidth x imageHeight。适应窗口的缩放比例double scale Math.Min(viewWidth / imageWidth, viewHeight / imageHeight); double offsetX (viewWidth - imageWidth * scale) / 2; double offsetY (viewHeight - imageHeight * scale) / 2;鼠标位置转图像坐标private Point ScreenToImage(Point p) { return new Point((p.X - offsetX) / scale, (p.Y - offsetY) / scale); }ROI 绘制时从图像坐标转屏幕坐标private Point ImageToScreen(Point p) { return new Point(p.X * scale offsetX, p.Y * scale offsetY); }这两组公式必须成对出现统一使用。一旦某个事件里用了e.GetPosition(_innerImage)另一个事件里又用e.GetPosition(canvas)坐标就会错位。我在代码里把所有鼠标获取点的地方都统一成e.GetPosition(this)然后用同一套换算函数这个问题才彻底消失。4.3 输出结果怎么转成 Halcon 可用的 ROI 参数控件做出来最终是要给算法用的。我的方案是定义了一个RoiOutputData类专门存放与算法库无关的几何参数例如ROI 类型输出字段对应 Halcon 算子矩形Row1, Column1, Row2, Column2gen_rectangle1旋转矩形Row, Column, Phi, Length1, Length2gen_rectangle2圆Row, Column, Radiusgen_circle椭圆Row, Column, Phi, Radius1, Radius2gen_ellipse在控件内部ROI 模型转换成输出数据时只做字段映射不直接引用 Halcon 程序集。这样当算法层真的是 Halcon 时只需要把RoiOutputData的值填入算子即可如果算法层换成 OpenCV也不影响 UI 控件的使用。这个解耦设计让控件成了一个纯粹的界面组件未来不管算法怎么变我都不需要再动交互层。5. 实测中踩过的坑与性能优化这个控件不是一次成型的。前面版本测下来遇到的问题不少我把最典型的几个记录下来希望对后来的人有帮助。5.1 高频 MouseMove 导致的卡顿与掉线第一次联调时发现拖动 ROI 时偶尔会有点“肉”不跟手。排查后发现原因不在 WPF 绘制本身而是我在MouseMove里每次都做了一堆不必要的操作。后来做了三个优化第一所有Pen、Brush对象只创建一次放到静态字段里缓存不要每次绘制时 new。WPF 的Pen和Brush都是 Freezable 对象反复创建会造成不必要的分配。第二更新 ROI 视觉时只更新当前被操作的那个DrawingVisual不需要刷新整个控件的所有视觉对象。操作方法是用DrawingContext重新绘制对应视觉的内容替换旧内容。第三给拖拽过程加了一个轻量节流。MouseMove只记录最新的鼠标位置实际重绘放到DispatcherTimer里间隔设为 16ms 左右。这样即使鼠标事件在极端情况下触发频率很高重绘节奏也是稳定可控的。_dragTimer new DispatcherTimer(DispatcherPriority.Render) { Interval TimeSpan.FromMilliseconds(16) }; _dragTimer.Tick (s, e) RefreshActiveRoiVisual();实测下来拖拽手感非常顺滑CPU 占用稳定。5.2 大图预览的内存占用问题有次测试加载一张 8000x6000 的工业图像控件刚开始渲染就出现了明显的卡顿。排查发现我直接把完整的BitmapSource交给控件去绘制WPF 每次渲染都要和这么大位图打交道性能自然上不去。后来加的优化是显示控件内部维护一个“预览图”根据控件尺寸把原图缩放到合适的显示分辨率。比如原图宽 8000控件显示区域只有 1200那就先生成一张宽约 1500 左右的预览图后续绘制全部基于预览图。这样做视觉上基本看不出差别但渲染性能提升非常明显。需要提醒的是缩略图只在“显示”阶段使用。ROI 计算、输出、算法处理仍然基于原始图像坐标这一点不能混。5.3 路由事件、鼠标捕获和 MVVM 的边界问题WPF 的鼠标路由事件很方便但也容易带来麻烦。比如你正在拖拽 ROI鼠标不小心移出了控件区域如果没做CaptureMouse()鼠标一离开控件就收不到MouseMoveROI 会停在半路。所以MouseLeftButtonDown时要调用CaptureMouse()MouseLeftButtonUp时再调用ReleaseMouseCapture()。MVVM 方面很多同事喜欢把所有逻辑都塞到 ViewModel 的 Command 里。对于 ROI 控件我的建议是保留事件出口而不是强求 Command 绑定。因为 ROI 交互本身很底层直接暴露RoiAdded、RoiDeleted、RoiChanged事件让 ViewModel 自己去订阅反而比写一堆ICommand再转换参数要清爽得多。控件内部不要弹任何MessageBox否则拖拽过程中弹窗会抢走鼠标焦点后续状态就全乱了。还有一个经验选中态高亮和实际数据修改要分开处理。比如点击 ROI 内部时先更新选中状态并刷新视觉不要马上修改 ROI 几何数据。等鼠标拖动真正产生位移了再更新数据模型。这样即使后续想加撤销功能也能区分“选中”和“编辑”两个动作。这个控件方案我至今仍在沿用。如果后续你有更复杂的交互需求比如支持多边形自由绘制、支持多个 ROI 组合同步变换、或者接入撤销重做框架当前的数据模型和视觉层拆分方式都可以平滑扩展。做这类控件最怕的就是一开始把所有东西写死在一起架构上多花点时间后面会省很多事。本文还有配套的精品资源点击获取
返回列表