简介:这是一套基于WPF的C#图像显示与ROI管理控件实现,面向工业检测、医学影像、教学演示等需要轻量级图像标注的桌面应用开发者。它无需依赖Halcon运行时,即可复现HSmartWindowControl的核心交互体验,支持图像加载、缩放、平移,以及矩形、圆形、多边形、椭圆等ROI的鼠标实时绘制、顶点拖拽调整、选中高亮、键盘删除与坐标批量导出,ROI数据结构独立封装,便于对接OpenCV或EmguCV处理流程。资源包共52个文件,约106KB,以26个cs源码、3个xaml界面、3个csproj工程文件为主,另含resx资源、axaml、json配置与sln解决方案等,涵盖RoiImplementation、Helper、Themes等模块及可运行的TestWindowWpf示例,全部使用原生WPF组件,无第三方UI库依赖。目前已有36人学习下载,适合希望快速掌握Halcon风格ROI交互逻辑、搭建自有图像标注控件的开发者参考复用。
1. WPF 自定义图像显示控件:把 Halcon 那套 ROI 交互搬进纯 C# 项目
做过机器视觉上位机的人大概都有这个体会:Halcon 的HWindowControl画 ROI 是真的顺手,画完还能直接拖、直接删,交互逻辑几乎不用自己操心。但一旦项目要求纯 WPF、不依赖 Halcon 运行时,或者只是想把 ROI 编辑能力嵌进一个自研的界面框架里,这套体验就得自己从零复刻。标题说的这件事,本质就是:用 WPF 写一个图像显示控件,把 Halcon 风格的鼠标绘制、拖拽、删除 ROI 区域这套交互逻辑,用纯 C# 实现出来。
它解决的是「图像显示 + 交互标注」这一层的问题,适合做视觉检测上位机、标注工具、测量软件的开发者。读完你应该能自己搭出一个可用的控件骨架,知道坐标怎么换算、命中测试怎么做、哪些参数必须提前定死。下面按「控件怎么搭 → 交互怎么做 → 坑在哪 → 怎么验证」的顺序讲。
2. 控件骨架与坐标系统:图像显示控件的三个核心层
2.1 为什么不用 Image 控件直接贴图
很多人第一反应是往Grid里塞一个Image,Source绑BitmapImage,然后在上层叠Canvas画 ROI。这个做法能跑,但很快就会翻车。原因是Image控件默认按Stretch缩放,你画在Canvas上的矩形坐标是屏幕坐标,跟图像像素坐标对不上,缩放窗口后 ROI 位置全飘。
正确的分层是三层:底层用Image或自绘的DrawingVisual负责像素渲染,中间层维护「图像坐标 ↔ 控件坐标」的变换矩阵,顶层用Canvas或自绘层承载 ROI 图元。关键是中间那层变换,它决定了后面所有交互的准确性。
我一般会把变换封装成一个ImageTransform类,内部维护缩放比Scale和平移量Offset,提供ImageToControl和ControlToImage两个方法。所有鼠标事件拿到的点,先转成图像坐标再参与 ROI 计算,这样无论窗口怎么缩放,ROI 的像素坐标始终稳定。
2.2 用 DrawingVisual 承载图像与 ROI 的渲染
如果只显示一张图,Image控件够用。但要做 ROI 高亮、选中态、拖拽预览,自绘更可控。下面是一个最小渲染骨架,用DrawingVisual把图像和 ROI 画在同一层,靠RenderOpen控制绘制顺序。
public class ImageCanvas : FrameworkElement { private DrawingVisual _imageVisual = new DrawingVisual(); private DrawingVisual _roiVisual = new DrawingVisual(); private ImageTransform _transform = new ImageTransform(); public ImageCanvas() { // 把两个 Visual 加入视觉树,顺序决定层级:图像在下,ROI 在上 AddVisualChild(_imageVisual); AddVisualChild(_roiVisual); } protected override int VisualChildrenCount => 2; protected override Visual GetVisualChild(int index) => index == 0 ? _imageVisual : _roiVisual; // 图像更新时重绘底层 public void SetImage(BitmapSource bitmap) { using (var dc = _imageVisual.RenderOpen()) { var rect = new Rect(0, 0, bitmap.PixelWidth, bitmap.PixelHeight); dc.DrawImage(bitmap, rect); } _transform.FitTo(ActualWidth, ActualHeight, bitmap.PixelWidth, bitmap.PixelHeight); InvalidateVisual(); } // ROI 变化时只重绘上层,避免整图重绘 public void RedrawRois(IEnumerable<RoiBase> rois) { using (var dc = _roiVisual.RenderOpen()) { foreach (var roi in rois) roi.Draw(dc, _transform, roi.IsSelected); } } }逻辑说明:AddVisualChild的顺序就是渲染层级,图像 Visual 先加,ROI Visual 后加,ROI 自然盖在图像上。SetImage里用DrawImage把位图按像素尺寸画到(0,0)起点,再调用FitTo计算适配缩放。RedrawRois单独重绘 ROI 层,拖拽时只刷这一层,帧率会明显好于整图重绘。
参数说明:FitTo内部要处理「图像比控件大」和「图像比控件小」两种情况,前者算缩小比,后者算放大比,同时把图像居中,Offset就是居中后的偏移量。这个偏移量在坐标反算时必须减掉,否则 ROI 会整体偏一个边距。
2.3 坐标换算:所有交互的地基
坐标换算写错,后面全是玄学。核心就两个公式,但边界情况不少。
public class ImageTransform { public double Scale { get; private set; } = 1.0; public double OffsetX { get; private set; } public double OffsetY { get; private set; } public void FitTo(double ctrlW, double ctrlH, int imgW, int imgH) { // 取宽高缩放比中较小的一个,保证图像完整显示 Scale = Math.Min(ctrlW / imgW, ctrlH / imgH); OffsetX = (ctrlW - imgW * Scale) / 2.0; OffsetY = (ctrlH - imgH * Scale) / 2.0; } // 图像坐标 -> 控件坐标 public Point ImageToControl(double x, double y) => new Point(x * Scale + OffsetX, y * Scale + OffsetY); // 控件坐标 -> 图像坐标 public Point ControlToImage(double x, double y) => new Point((x - OffsetX) / Scale, (y - OffsetY) / Scale); }逻辑说明:FitTo用Math.Min保证图像完整可见,不会出现一边被裁掉的情况。两个转换方法互为逆运算,ControlToImage先减偏移再除缩放,顺序不能反。
参数说明:Scale是唯一需要持久化的状态,OffsetX/Y每次尺寸变化都要重算。如果后续要支持鼠标滚轮缩放,Scale会变成用户可控值,此时Offset要围绕鼠标位置重新计算,否则缩放中心会跑到左上角,这是最常见的翻车点之一。
3. ROI 图元与鼠标交互:绘制、拖拽、删除怎么落地
3.1 ROI 基类设计:把绘制和命中测试收口
ROI 类型通常有矩形、圆、多边形、线段。与其写四套鼠标逻辑,不如抽一个基类,把「怎么画」和「点没点中」交给子类实现,交互流程统一在控件层调度。
public abstract class RoiBase { public bool IsSelected { get; set; } public int HandleSize { get; set; } = 6; // 控制点半径,单位像素 // 子类实现:把自己画到 DrawingContext public abstract void Draw(DrawingContext dc, ImageTransform t, bool selected); // 子类实现:判断图像坐标点是否落在 ROI 内 public abstract bool HitTest(Point imgPt, double tolerance); // 子类实现:返回所有可拖拽的控制点(图像坐标) public abstract IEnumerable<Point> GetHandles(); // 子类实现:整体平移 public abstract void Translate(double dx, double dy); }逻辑说明:Draw负责渲染,HitTest负责命中判断,GetHandles返回控制点用于拖拽变形,Translate用于整体移动。控件层只认这四个方法,新增 ROI 类型不用改交互代码。
参数说明:HandleSize是控制点的屏幕像素半径,注意它是屏幕单位不是图像单位,命中测试时要先把鼠标点转成图像坐标,再用tolerance / Scale换算成图像单位的容差,否则缩放后控制点会变得极难点中。
3.2 鼠标状态机:绘制、拖拽、删除的完整流程
交互的本质是一个状态机。我一般定义四个状态:Idle、Drawing、Dragging、Resizing。鼠标左键按下时根据当前命中的对象决定进入哪个状态,移动时更新,抬起时收尾。
private enum EditState { Idle, Drawing, Dragging, Resizing } private EditState _state = EditState.Idle; private RoiBase _activeRoi; private Point _startImgPt; protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { var imgPt = _transform.ControlToImage( e.GetPosition(this).X, e.GetPosition(this).Y); // 1. 先判断是否点中已有 ROI 的控制点 var handle = HitHandle(imgPt); if (handle != null) { _state = EditState.Resizing; _activeRoi = handle.Roi; return; } // 2. 再判断是否点中 ROI 本体,进入拖拽 var hit = _rois.LastOrDefault(r => r.HitTest(imgPt, 5 / _transform.Scale)); if (hit != null) { SelectOnly(hit); _state = EditState.Dragging; _activeRoi = hit; _startImgPt = imgPt; return; } // 3. 空白处按下,开始绘制新 ROI _state = EditState.Drawing; _startImgPt = imgPt; _activeRoi = CreateRoiByTool(_currentTool, imgPt); _rois.Add(_activeRoi); }逻辑说明:按下时按「控制点 → ROI 本体 → 空白」的优先级判断,这个顺序不能乱,否则点控制点会被当成拖拽整体。HitTest的容差用5 / Scale换算,保证屏幕上的手感一致。
参数说明:_currentTool是当前工具类型,决定CreateRoiByTool造出矩形还是圆。SelectOnly负责清掉其他 ROI 的选中态,保证同一时刻只有一个选中。
3.3 删除与键盘交互:别让 Delete 键失灵
删除看起来简单,但焦点问题经常让人抓狂。FrameworkElement默认不接收键盘事件,必须设Focusable = true并在鼠标按下时Focus(),否则按 Delete 毫无反应。
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { Focus(); // 关键:拿到键盘焦点 // ... 上面的状态机逻辑 } protected override void OnKeyDown(KeyEventArgs e) { if (e.Key == Key.Delete && _selectedRoi != null) { _rois.Remove(_selectedRoi); _selectedRoi = null; RedrawRois(_rois); e.Handled = true; } }逻辑说明:Focus()必须在鼠标按下时调用,因为 WPF 的键盘事件沿着焦点链传递,没有焦点就收不到OnKeyDown。删除后立即重绘并清空选中引用,避免悬空引用。
参数说明:如果控件被放在ScrollViewer里,Delete 可能被外层吞掉,此时要在OnKeyDown里设e.Handled = true阻止冒泡。多选删除时把_selectedRoi换成集合即可,逻辑一致。
4. 避坑与排查:ROI 交互里最容易翻车的五件事
4.1 现象:缩放窗口后 ROI 位置整体偏移
原因:坐标换算时只除了缩放比,忘了减平移偏移,或者Offset在尺寸变化后没重算。FitTo只在SetImage时调了一次,窗口 resize 后Scale和Offset都是旧值。
解决:在OnRenderSizeChanged里重新调用FitTo,并触发一次 ROI 重绘。所有鼠标事件里的坐标转换必须走ControlToImage,禁止手写x / Scale。
4.2 现象:控制点在小缩放比下点不中
原因:命中容差用了固定的图像单位,比如写死tolerance = 5。图像放大到 10 倍时,5 个图像像素在屏幕上只有 0.5 像素,根本点不到。
解决:容差始终以屏幕像素为准,命中测试前用tolerance / Scale换算成图像单位。控制点半径同理,绘制时用屏幕单位,命中时换算。
4.3 现象:拖拽时 ROI 抖动、跟手性差
原因:每次MouseMove都触发整图重绘,或者拖拽增量累加时用了错误的基准点。常见错误是每帧用「当前点减上一个点」累加,误差会累积。
解决:拖拽开始时记录_startImgPt和 ROI 的原始几何,移动时用「当前点减起始点」得到总位移,再作用到原始几何上,而不是逐帧累加。重绘只刷 ROI 层。
4.4 现象:Delete 键没反应,或者误删了别的 ROI
原因:控件没拿到键盘焦点,或者_selectedRoi在重绘后没同步更新,导致删的是上一次选中的对象。
解决:鼠标按下时Focus();选中态变化时立即更新_selectedRoi;删除后清空引用。如果界面里有多个可聚焦控件,考虑用Keyboard.Focus(this)显式抢焦点。
4.5 现象:ROI 画到图像边界外,坐标出现负数
原因:绘制时没做边界钳制,鼠标拖到控件边缘外,图像坐标变成负值或超过图像尺寸。
解决:在ControlToImage之后加一层Clamp,把坐标限制在[0, imgW]和[0, imgH]范围内。注意钳制要在图像坐标下做,不要在控件坐标下做,否则缩放后会错位。
5. 进阶技巧:用命中测试优先级和渲染缓存把交互做顺
前面把骨架和交互讲完了,这一章说两个能让体验明显上一个台阶的技巧,也是我自己踩过坑之后固定下来的习惯。
第一个是命中测试的优先级设计。很多人把所有 ROI 平铺在一个列表里,HitTest从头遍历到尾,谁先命中算谁。问题是 ROI 重叠时,你永远选不中下面那个。我的做法是维护一个 Z 序,选中或新建的 ROI 移到列表末尾,命中测试从末尾往前遍历,这样「最后操作的在最上层」符合直觉。控制点的命中永远优先于 ROI 本体,因为控制点更小、更精确,用户点它就是想变形而不是整体移动。
private RoiHandle HitHandle(Point imgPt) { // 从 Z 序顶层往下找,先命中的控制点优先 for (int i = _rois.Count - 1; i >= 0; i--) { var roi = _rois[i]; if (!roi.IsSelected) continue; // 只有选中的 ROI 显示控制点 foreach (var h in roi.GetHandles()) { var d = (h - imgPt).Length; if (d <= roi.HandleSize / _transform.Scale) return new RoiHandle(roi, h); } } return null; }逻辑说明:只对选中的 ROI 做控制点命中,未选中的 ROI 不显示控制点也不参与,减少误判。从列表末尾往前遍历,保证顶层优先。距离判断用图像坐标,容差换算成图像单位。
参数说明:HandleSize建议设 6 到 8 个屏幕像素,太小难点,太大容易和 ROI 本体命中冲突。如果 ROI 很密集,可以把控制点命中容差设得比本体命中容差略小,形成层次。
第二个是渲染缓存。ROI 数量多的时候,每次MouseMove都重建所有DrawingContext会卡。我的做法是给每个 ROI 缓存一个Geometry,只在几何真正变化时重建,Draw时直接dc.DrawGeometry。矩形和圆的几何重建成本低,多边形和样条收益明显。另外,拖拽过程中可以只画一个半透明的预览框,鼠标抬起才提交到真实几何,这样即使 ROI 上千个,拖拽也不掉帧。
验证方法很简单:造 200 个矩形 ROI,开任务管理器拖一圈,看 CPU 占用。如果拖拽时某个核跑满,基本就是重绘没做缓存。把RedrawRois改成只重绘变化的那一个 ROI,占用会立刻下来。
最后说个我自己的习惯:每次改完坐标换算相关的代码,我一定会在ControlToImage和ImageToControl里各打一个断点,用同一个鼠标点来回转一次,确认转回来的值和原始值误差在 0.01 像素以内。这个「后悔药」动作帮我省过至少三次上线后的坐标偏移事故。ROI 交互这东西,玄学都出在坐标上,把坐标钉死,剩下的就是体力活。希望帮到你。
本文还有配套的精品资源,点击获取