简介:一套基于WPF的C#图像显示与ROI管理控件,面向工业检测、医学影像、教学演示等需要轻量级标注能力的桌面开发场景,可在不依赖Halcon运行时的情况下复现HSmartWindowControl的核心交互体验。控件支持图像加载、缩放、平移,以及矩形、圆形、多边形、椭圆等常见ROI的鼠标实时绘制、顶点拖拽调整、选中高亮、键盘删除和批量导出坐标数据;内部采用Canvas+Shape组合渲染,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的显示控件,承载所有视觉绘制。RoiBase:ROI 数据模型抽象基类,包含几何判断、平移、绘制等能力。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控件的Stretch="Uniform"属性,图像确实能居中缩放,但当你想把鼠标坐标转换成图像坐标时,必须自己去算实际的绘制区域。
我最终选择完全不用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, Column2 | gen_rectangle1 |
| 旋转矩形 | Row, Column, Phi, Length1, Length2 | gen_rectangle2 |
| 圆 | Row, Column, Radius | gen_circle |
| 椭圆 | Row, Column, Phi, Radius1, Radius2 | gen_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(),鼠标一离开控件就收不到MouseMove,ROI 会停在半路。所以MouseLeftButtonDown时要调用CaptureMouse(),MouseLeftButtonUp时再调用ReleaseMouseCapture()。
MVVM 方面,很多同事喜欢把所有逻辑都塞到 ViewModel 的 Command 里。对于 ROI 控件,我的建议是保留事件出口,而不是强求 Command 绑定。因为 ROI 交互本身很底层,直接暴露RoiAdded、RoiDeleted、RoiChanged事件,让 ViewModel 自己去订阅,反而比写一堆ICommand再转换参数要清爽得多。控件内部不要弹任何MessageBox,否则拖拽过程中弹窗会抢走鼠标焦点,后续状态就全乱了。
还有一个经验:选中态高亮和实际数据修改要分开处理。比如点击 ROI 内部时,先更新选中状态并刷新视觉,不要马上修改 ROI 几何数据。等鼠标拖动真正产生位移了,再更新数据模型。这样即使后续想加撤销功能,也能区分“选中”和“编辑”两个动作。
这个控件方案我至今仍在沿用。如果后续你有更复杂的交互需求,比如支持多边形自由绘制、支持多个 ROI 组合同步变换、或者接入撤销重做框架,当前的数据模型和视觉层拆分方式都可以平滑扩展。做这类控件最怕的就是一开始把所有东西写死在一起,架构上多花点时间,后面会省很多事。
本文还有配套的精品资源,点击获取