简介:这是一套基于WPF的C#图像显示与ROI管理控件实现,面向工业检测、医学影像及教学演示等需要轻量级图像标注的桌面应用开发者。它无需依赖Halcon运行时,即可复现HSmartWindowControl的核心交互体验,支持图像加载、缩放、平移,以及矩形、圆形、多边形、椭圆等ROI的鼠标实时绘制、顶点拖拽调整、选中高亮、键盘删除与坐标批量导出,ROI数据结构独立封装,便于对接OpenCV或EmguCV处理流程。资源包共52个文件,约106KB,以26个cs源码、3个xaml与2个axaml界面文件、3个csproj工程文件为主,另含resx资源、json配置及sln解决方案等,涵盖主界面、辅助类、主题样式与ROI实现模块,全部使用原生WPF组件,无第三方UI库依赖。项目内含完整可运行的TestWindowWpf示例,便于直接调试与二次开发。目前已有36人学习下载,适合希望快速搭建图像标注功能的开发者参考。
1. 从 Halcon 的 Draw 系列算子说起:为什么 WPF 里要自己造一个 ROI 控件
如果你之前用 Halcon 做过视觉项目,大概率对draw_rectangle1、draw_circle、draw_polygon这几个算子又爱又恨。爱的是交互逻辑极其顺手——鼠标按下拖出矩形,滚轮缩放,右键结束,ROI 句柄随手就能拖拽修改;恨的是这套交互被锁死在 Halcon 的窗口句柄里,一旦要把界面换成 WPF,原生 HWindowControl 的嵌入体验、DPI 适配、多图层叠加就处处别扭。更现实的问题是:很多产线软件要求 ROI 区域能持久化、能绑定 MVVM、能跟业务数据联动,而 Halcon 的窗口交互模型和 WPF 的视觉树是两套体系。
所以「WPF 自定义图像显示控件,支持鼠标绘制/拖拽/删除 ROI 区域,兼容 Halcon 风格交互逻辑」这件事,本质上是把 Halcon 那套已经被验证过的交互手感,用 WPF 的 Canvas + Adorner + 依赖属性重新实现一遍。它解决的不是「能不能画个框」的问题,而是「画完之后这个框能不能被业务代码当数据用、能不能跟着图像缩放平移、能不能在多个 ROI 之间切换编辑」的问题。适合谁?适合正在把视觉算法从 Halcon/MIL 往自研 C# 平台迁移的团队,也适合需要在 WPF 里做标注工具、缺陷框选、模板匹配区域设定的开发者。下面我按自己踩过的路径,从控件骨架到交互状态机,一步步拆开讲。
2. 控件骨架怎么搭:从 Image 到 Canvas 的图层拆分
2.1 为什么不能直接在 Image 上画
最常见的翻车做法是继承Image控件,然后在OnRender里直接画矩形。这样做的后果是:图像缩放时 ROI 坐标不跟着变,鼠标命中测试永远对不上,多个 ROI 叠加时 z-order 混乱。正确的思路是分层——底层放图像,中间层放 ROI 图形,顶层放交互句柄(Adorner)。WPF 里最稳的组合是Grid套Image+Canvas,Canvas 的RenderTransform和 Image 的RenderTransform共享同一个矩阵。
// 控件模板的核心结构,用 Grid 做三层叠加 <Grid x:Name="PART_Root"> <!-- 底层:图像显示,Stretch=Uniform 保证比例 --> <Image x:Name="PART_Image" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"/> <!-- 中间层:ROI 图形宿主,坐标系统一由 Matrix 控制 --> <Canvas x:Name="PART_RoiLayer" Background="Transparent" ClipToBounds="True"/> <!-- 顶层:Adorner 装饰层,用于拖拽句柄 --> <AdornerDecorator x:Name="PART_AdornerLayer"/> </Grid>逻辑说明:PART_Image负责显示位图,PART_RoiLayer是一个透明 Canvas,所有 ROI 的Rectangle、Ellipse、Polygon都加到这里。关键参数是ClipToBounds="True",防止 ROI 拖出图像边界后还在外面显示。AdornerDecorator是 WPF 自带的装饰层,用来放拖拽句柄,这样句柄的渲染不干扰 ROI 本身的命中测试。
2.2 坐标系统一:图像坐标与控件坐标的换算
Halcon 的交互逻辑里,ROI 坐标始终是图像像素坐标,跟窗口大小无关。WPF 里必须做一次矩阵换算。我一般会在控件里维护一个Matrix对象,图像加载时根据Stretch=Uniform计算缩放比和偏移量。
// 计算图像坐标到控件坐标的变换矩阵 private Matrix _imageToControlMatrix; private void UpdateTransformMatrix() { if (PART_Image.Source == null) return; var imgW = PART_Image.Source.Width; var imgH = PART_Image.Source.Height; var ctrlW = PART_Image.ActualWidth; var ctrlH = PART_Image.ActualHeight; // Uniform 缩放取较小比例 var scale = Math.Min(ctrlW / imgW, ctrlH / imgH); var offsetX = (ctrlW - imgW * scale) / 2; var offsetY = (ctrlH - imgH * scale) / 2; _imageToControlMatrix = new Matrix(scale, 0, 0, scale, offsetX, offsetY); }参数说明:scale是图像到控件的缩放比,offsetX/offsetY是居中留白偏移。所有鼠标事件拿到的点,先用这个矩阵的逆矩阵转回图像坐标,再存到 ROI 数据模型里。这样无论窗口怎么拉伸,ROI 在图像上的绝对位置不变。这一步不做,后面拖拽就会飘。
2.3 ROI 数据模型与依赖属性设计
ROI 不能只存在 Canvas 的 Children 里,那样业务层拿不到数据。我一般定义一个RoiItem类,继承DependencyObject,暴露RoiType、Points、IsSelected等依赖属性,然后用ItemsControl绑定。但考虑到性能,ROI 数量通常不多(几十个以内),直接操作 Canvas 子元素也够用。折中方案是:数据模型和视觉元素通过一个字典做映射。
public enum RoiType { Rectangle, Circle, Polygon } public class RoiItem : DependencyObject { public static readonly DependencyProperty RoiTypeProperty = DependencyProperty.Register(nameof(RoiType), typeof(RoiType), typeof(RoiItem)); public RoiType RoiType { get; set; } // 图像坐标系下的点集,矩形存两个对角点,多边形存所有顶点 public PointCollection Points { get; set; } = new PointCollection(); public bool IsSelected { get; set; } // 视觉元素引用,方便同步更新 internal Shape Visual { get; set; } }逻辑说明:Points始终存图像坐标,视觉元素的位置通过矩阵换算后设置。Visual字段是内部引用,不暴露给业务层,避免业务代码直接改 UI 元素导致状态不一致。这样设计后,保存 ROI 就是序列化Points,加载就是反序列化后重建视觉元素。
3. 鼠标交互状态机:绘制、拖拽、删除怎么不打架
3.1 用状态枚举管住鼠标事件
Halcon 风格交互最核心的一点是:不同模式下鼠标行为完全不同。画矩形时左键按下开始、移动预览、松开结束;选择模式下左键点中 ROI 是选中、点中句柄是拖拽、点空白是取消选中。如果不用状态机,MouseDown里堆一堆 if-else,后期加个多边形绘制就会崩。我一般定义一个InteractionState枚举。
private enum InteractionState { Idle, // 空闲,可选中 Drawing, // 正在绘制新 ROI Dragging, // 正在拖拽 ROI 整体 Resizing, // 正在拖拽句柄改大小 Deleting // 删除模式,点击即删 } private InteractionState _state = InteractionState.Idle; private Point _startImagePoint; // 按下时的图像坐标 private RoiItem _activeRoi; // 当前操作的 ROI private int _activeHandleIndex = -1; // 当前拖拽的句柄索引参数说明:_startImagePoint存的是图像坐标,不是控件坐标,这样缩放窗口时拖拽逻辑不受影响。_activeHandleIndex用于矩形四个角、多边形各顶点的句柄区分,-1 表示整体拖拽。
3.2 绘制矩形的完整鼠标事件链
以矩形为例,从MouseLeftButtonDown到MouseLeftButtonUp的完整链路如下。注意所有坐标转换都走_imageToControlMatrix的逆矩阵。
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { var ctrlPoint = e.GetPosition(PART_RoiLayer); var imgPoint = _imageToControlMatrix.Inverse.Transform(ctrlPoint); if (_state == InteractionState.Idle && _currentTool == RoiType.Rectangle) { _startImagePoint = imgPoint; _activeRoi = new RoiItem { RoiType = RoiType.Rectangle }; _activeRoi.Points.Add(imgPoint); _activeRoi.Points.Add(imgPoint); AddVisual(_activeRoi); _state = InteractionState.Drawing; CaptureMouse(); // 捕获鼠标,防止移出控件丢失事件 } else if (_state == InteractionState.Idle) { // 命中测试,判断点中了哪个 ROI 或句柄 HitTestRoi(imgPoint); } e.Handled = true; } protected override void OnMouseMove(MouseEventArgs e) { if (_state != InteractionState.Drawing) return; var ctrlPoint = e.GetPosition(PART_RoiLayer); var imgPoint = _imageToControlMatrix.Inverse.Transform(ctrlPoint); // 更新第二个点,实时刷新视觉 _activeRoi.Points[1] = imgPoint; UpdateVisual(_activeRoi); } protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { if (_state == InteractionState.Drawing) { _state = InteractionState.Idle; ReleaseMouseCapture(); // 过滤掉过小的误触矩形,比如宽高小于 3 像素 if (IsRoiValid(_activeRoi)) RaiseRoiCreated(_activeRoi); else RemoveVisual(_activeRoi); } }逻辑说明:CaptureMouse是关键,没有它鼠标移出控件边界后MouseMove就断了,矩形画一半停住。IsRoiValid做最小尺寸过滤,避免单击误触产生零面积 ROI。RaiseRoiCreated是自定义路由事件,业务层订阅后就能拿到新 ROI 数据。
3.3 拖拽与句柄命中测试
拖拽分两种:整体拖拽和句柄缩放。命中测试的顺序必须是先句柄后 ROI 本体,否则句柄永远点不中。句柄我用Adorner实现,每个选中的 ROI 生成一个RoiAdorner,在四个角画小方块。
private void HitTestRoi(Point imgPoint) { // 先检查当前选中 ROI 的句柄 if (_selectedRoi != null) { var handleIndex = HitTestHandle(_selectedRoi, imgPoint); if (handleIndex >= 0) { _activeRoi = _selectedRoi; _activeHandleIndex = handleIndex; _state = InteractionState.Resizing; CaptureMouse(); return; } } // 再检查 ROI 本体,从后往前遍历,后添加的在上层 for (int i = _roiList.Count - 1; i >= 0; i--) { if (IsPointInRoi(_roiList[i], imgPoint)) { SelectRoi(_roiList[i]); _activeRoi = _roiList[i]; _state = InteractionState.Dragging; _dragOffset = imgPoint - _roiList[i].Points[0]; // 记录偏移 CaptureMouse(); return; } } // 点空白,取消选中 SelectRoi(null); }参数说明:_dragOffset记录按下点与 ROI 参考点的差值,拖拽时用当前点减去偏移得到新位置,这样 ROI 不会跳到鼠标正下方。IsPointInRoi对矩形用坐标范围判断,对圆形用距离判断,对多边形用射线法。句柄命中测试的容差一般设 5 个图像像素,太小不好点,太大容易误触。
3.4 删除 ROI 的两种方式
删除我一般做两种:按 Delete 键删除选中 ROI,以及右键菜单删除。Halcon 里常用的是右键结束绘制,所以右键菜单要避开绘制状态。
protected override void OnKeyDown(KeyEventArgs e) { if (e.Key == Key.Delete && _selectedRoi != null) { RemoveRoi(_selectedRoi); e.Handled = true; } } private void RemoveRoi(RoiItem roi) { RemoveVisual(roi); _roiList.Remove(roi); if (_selectedRoi == roi) _selectedRoi = null; RaiseRoiDeleted(roi); // 通知业务层 }逻辑说明:删除时先移除视觉元素,再从列表移除,最后触发事件。顺序不能反,否则 Adorner 层可能残留句柄。业务层收到RoiDeleted后同步更新自己的数据集合。
4. 避坑与排查:ROI 控件最容易翻车的 5 个地方
4.1 现象:窗口缩放后 ROI 位置偏移
原因:只更新了图像的RenderTransform,没有同步更新 Canvas 的坐标换算矩阵。或者用了Stretch=Fill导致图像比例失真,ROI 跟着拉伸。
解决:统一用Stretch=Uniform,在SizeChanged事件里重新计算_imageToControlMatrix,然后遍历所有 ROI 重新UpdateVisual。不要试图用Viewbox包裹,Viewbox 的缩放会干扰鼠标坐标换算。
4.2 现象:拖拽 ROI 时鼠标和图形不同步,越拖越远
原因:拖拽时用了控件坐标做增量,但 ROI 存的是图像坐标,两者缩放比不一致。或者_dragOffset没有在按下时记录,每次移动都重新算。
解决:所有拖拽计算都在图像坐标系里做。按下时记录_dragOffset = imgPoint - roi.Points[0],移动时newPoint = currentImgPoint - _dragOffset,然后整体平移 ROI 的所有点。这样无论缩放比多少,拖拽手感都是 1:1。
4.3 现象:多边形 ROI 顶点顺序错乱,填充区域自交
原因:绘制多边形时用户可能顺时针也可能逆时针点,甚至交叉点。直接按点击顺序连线和填充,WPF 的Polygon会按奇偶规则填充,自交区域出现空洞。
解决:绘制结束后对顶点做一次凸包或简单排序。如果业务允许,限制多边形为凸多边形,用角度排序。更简单的做法是绘制时实时显示连线,让用户自己避免交叉,结束后不做处理但提示。我一般用角度排序,以质心为参考点。
4.4 现象:ROI 数量多时拖拽卡顿
原因:每次MouseMove都触发UpdateVisual,而UpdateVisual里创建了新的PointCollection或触发了布局重算。
解决:UpdateVisual只改现有Shape的Points或Width/Height,不重建对象。对于矩形,直接设Canvas.Left、Canvas.Top、Width、Height。对于多边形,复用PointCollection并调用InvalidateVisual。另外MouseMove里加一个节流,比如用CompositionTarget.Rendering合并更新。
4.5 现象:Adorner 句柄在图像缩放后位置不对
原因:Adorner 的GetDesiredTransform没有考虑图像矩阵,句柄画在了控件坐标但 ROI 在图像坐标。
解决:在RoiAdorner里重写GetDesiredTransform,把_imageToControlMatrix乘进去。或者更简单:Adorner 的OnRender里手动把图像坐标转成控件坐标再画。我倾向后者,逻辑更直白,调试时一眼能看出坐标对不对。
5. 进阶技巧:把 ROI 交互做成可复用的行为组件
5.1 用 Behavior 把交互逻辑从控件里剥出来
控件写到最后,OnMouseMove里往往堆了几百行。更好的做法是把交互逻辑抽成Behavior<Canvas>,控件只负责显示和坐标换算。这样同一个 Behavior 可以挂到不同的图像控件上,也方便单元测试。
public class RoiInteractionBehavior : Behavior<Canvas> { private InteractionState _state = InteractionState.Idle; private Matrix _matrix; protected override void OnAttached() { AssociatedObject.MouseLeftButtonDown += OnDown; AssociatedObject.MouseMove += OnMove; AssociatedObject.MouseLeftButtonUp += OnUp; } // 矩阵由外部注入,解耦坐标换算 public void SetTransformMatrix(Matrix m) => _matrix = m; }逻辑说明:Behavior 通过AssociatedObject拿到 Canvas,事件订阅在OnAttached里做,OnDetaching里取消。矩阵由宿主控件注入,Behavior 不关心图像怎么缩放,只关心交互状态。这样换一个图像控件,只要注入矩阵就能复用。
5.2 序列化与反序列化:ROI 数据怎么存
ROI 最终要存成文件或数据库。我一般用 JSON,因为PointCollection可以直接序列化。但要注意存的是图像坐标,加载时如果图像尺寸变了,需要按比例缩放。
public class RoiDto { public string Type { get; set; } public List<double> X { get; set; } = new(); public List<double> Y { get; set; } = new(); } // 保存 var dto = new RoiDto { Type = roi.RoiType.ToString() }; foreach (var p in roi.Points) { dto.X.Add(p.X); dto.Y.Add(p.Y); } var json = JsonSerializer.Serialize(dto); // 加载时按图像尺寸比例还原 var scaleX = currentImageWidth / savedImageWidth; var scaleY = currentImageHeight / savedImageHeight;参数说明:scaleX/scaleY用于图像分辨率变化时的坐标还原。如果图像尺寸不变,这两个值为 1。存的时候建议同时存原始图像宽高,加载时才能算比例。
5.3 一个验证交互是否正确的笨办法
写完控件后,我习惯做一个「坐标回显」测试:在鼠标移动时实时显示当前图像坐标,然后画一个矩形,手动算一下四个角的坐标是否和回显一致。再缩放窗口,看 ROI 是否跟着图像走。最后拖拽 ROI 到图像边缘,看是否被ClipToBounds裁掉。这三步能过滤掉 90% 的坐标换算 bug。
5.4 关于 Halcon 风格兼容的一点个人习惯
Halcon 的draw_rectangle1是拖出对角线,draw_rectangle2是带角度的,draw_circle是圆心加半径。我在 WPF 里实现时,矩形用对角线,圆形用圆心加半径,多边形用逐点点击、右键结束。滚轮缩放图像时,ROI 不缩放,只跟着图像平移。这个手感和 Halcon 基本一致,用户迁移过来不需要重新学。唯一要注意的是右键:Halcon 里右键结束绘制,WPF 里右键可能触发 ContextMenu,我一般把绘制状态的右键Handled设为 true,屏蔽菜单。
写这个控件最大的教训是:坐标换算一定要在图像坐标系里做,所有鼠标事件第一件事就是转坐标。我早期偷懒在控件坐标里算,结果窗口一缩放就全乱,调了两天才找到原因。现在养成的习惯是,任何跟 ROI 位置相关的变量,命名都带Image后缀,看到ctrlPoint就知道它只是临时用一下。希望帮到你。
本文还有配套的精品资源,点击获取