资讯动态

C# WPF图像控件与OpenCV ROI交互实现

发布时间:2026/10/26 2:07:07 来源:尧图企业网站定制
简介面向WPF机器视觉开发者的C#图像控件与ROI管理示例聚焦仿Halcon HSmartWindowControl的交互体验解决图像加载显示、感兴趣区域绘制与增删改管理等常见需求适合具备一定C#与WPF基础、希望理解视觉界面控件设计的开发者参考。压缩包共86个文件约319KB以31个cs源码为核心辅以xaml界面、csproj与sln解决方案、dll依赖及配置资源整体体量轻便便于直接打开工程梳理结构。已有412人学习下载说明该主题在图像处理与工业视觉界面开发中具有一定关注度。内容围绕自定义图像控件、ROI数据结构与交互事件展开可从中了解图像与ROI的模块化组织、鼠标操作响应、图形绘制以及结合OpenCV进行扩展的思路也能借鉴项目分层和接口设计方式为后续实现模板匹配、自动检测等功能提供可复用的工程骨架。1. 从一块「不听话」的显示区说起这套 C# WPF 图像控件到底解决了什么在 WPF 里做视觉上位机图像显示和 ROI 交互往往是第一个卡住进度的模块商业控件 HSmartWindowControl 的交互手感确实好但授权、部署、定制都受制自己用 OpenCV 读图再写一个 C# 自定义图像控件把 ROI 的绘制与管理做扎实反而更可控。这套实现面向的就是这类场景用 WPF 原生绘制承载图像用 OpenCV 做像素进出把矩形、圆、多边形、线段的 ROI 交互和集合管理收在一个控件里仿的是 Halcon HSmartWindowControl 那套「滚轮缩放、拖拽平移、句柄编辑」的手感。它适合正在做工业视觉、医疗影像、标注工具上位机的 WPF 开发尤其是那种不想被商业控件绑死、又需要自己扩展 ROI 类型的项目。读下去你会看到图像层、ROI 层、交互层怎么拆鼠标状态机怎么落以及缩放后 ROI 偏移、图像发黑、拖拽卡顿这些坑怎么排。2. 架构拆解图像层、ROI 层、交互层怎么分才不至于糊成一坨2.1 目录结构与核心类职责我一般会把图像显示和 ROI 交互拆成两个FrameworkElement而不是把所有逻辑塞进一个自定义控件。图像层只负责BitmapSource的绘制、缩放、平移ROI 层只负责把图像坐标映射到控件坐标并画出来鼠标状态机放在 ROI 层RoiManager只管理集合不碰 WPF 渲染。这样拆的好处是ROI 层可以单独设IsHitTestVisiblefalse做只读预览也可以叠多个层做不同颜色的标注OpenCV 的Mat也不会顺着 UI 引用到处跑。路径职责关键点Controls/ImageCanvas.cs图像绘制、缩放、平移重写OnRender维护Scale、OffsetControls/RoiOverlay.csROI 绘制、命中测试、鼠标状态机只存图像坐标渲染时统一映射Models/RoiBase.csROI 抽象基类Draw、HitTest、Move、Resize、GetHandles、ToDtoModels/RectRoi.cs等具体 ROI 类型矩形、圆、多边形、线段各一个文件Services/RoiManager.csROI 集合与选中态增删、倒序命中测试、选中切换Services/RoiSerializer.csROI 序列化与反序列化DTO 类型工厂留版本字段Interop/MatBitmapConverter.csOpenCVMat转BitmapSource处理 stride、像素格式、Freeze这张表不是摆设后面改代码基本就按这张表找位置。比如新增一个「旋转矩形」ROI模型层加一个类工具枚举加一个值反序列化工厂加一个分支渲染和命中测试都在新类里闭环不需要回头改ImageCanvas。图像层和 ROI 层之间只通过一个ICoordinateMapper接口通信接口里就三个成员Scale、ImageToControl、ControlToImage。这样单元测试可以拿一个假的 mapper 跑不用起 WPF 窗口。2.2 渲染管线从 Mat 到 WriteableBitmap再到 OnRenderOpenCV 读进来的图是MatWPF 要显示得先转成BitmapSource。这里最常见的翻车点是 stride 和像素格式Mat的每一行可能有对齐填充Step()不一定等于Width * Channels直接拿Width * Channels当 stride图像就会斜着花。另一个坑是通道顺序OpenCV 默认Bgr24WPF 的PixelFormats.Bgr24正好对得上不用先转 RGB。下面这段转换代码是我在项目里反复用的版本public static BitmapSource ToBitmapSource(Mat mat) { if (mat null || mat.Empty()) return null; PixelFormat format; if (mat.Channels() 1) format PixelFormats.Gray8; else if (mat.Channels() 3) format PixelFormats.Bgr24; else if (mat.Channels() 4) format PixelFormats.Bgra32; else throw new NotSupportedException($不支持的通道数{mat.Channels()}); // Mat.Step() 是每一行的字节数包含 OpenCV 的对齐填充 int stride (int)mat.Step(); int bufferSize stride * mat.Height; // dpi 固定 96让 BitmapSource.Width 与 PixelWidth 一致坐标换算少一层坑 var bitmap BitmapSource.Create( mat.Width, mat.Height, 96, 96, format, null, mat.Data, bufferSize, stride); // Freeze 之后可以跨线程使用UI 线程只读不写 bitmap.Freeze(); return bitmap; }这段代码的逻辑很直先按通道数选PixelFormat单通道走Gray8三通道走Bgr24四通道走Bgra32stride必须用mat.Step()不能自己算bufferSize是stride * heightBitmapSource.Create会把Mat.Data指向的内存复制一份所以调用完就可以释放Mat不会出现「图显示到一半内存被回收」的黑匣子问题。参数里dpiX、dpiY固定 96是为了让后续ImageSource.Width等于像素宽度缩放计算不用再除 DPI。如果你拿到的Mat是 ROI 子矩阵Step()仍然可能是原图的行宽这段代码也能正确处理因为 stride 传的是真实行字节数。图像层拿到BitmapSource后在OnRender里按Offset和Scale画一个矩形区域protected override void OnRender(DrawingContext dc) { dc.DrawRectangle(Brushes.Black, null, new Rect(RenderSize)); if (ImageSource null) return; var destRect new Rect( Offset.X, Offset.Y, ImageSource.Width * Scale, ImageSource.Height * Scale); RenderOptions.SetBitmapScalingMode(this, BitmapScalingMode.HighQuality); dc.DrawImage(ImageSource, destRect); }destRect的左上角就是Offset宽高是图像 DIP 尺寸乘Scale。BitmapScalingMode.HighQuality在缩小时会做插值看起来更顺眼但大图频繁缩放时会吃性能如果项目里更在意拖拽帧率可以切成LowQuality或NearestNeighbor这个后面性能一节还会提。OnRender里不要做任何耗时的图像处理Mat转换、滤波、二值化都应该在后台线程做完UI 线程只负责把现成的BitmapSource画上去。2.3 依赖属性与控件契约ImageSource、Scale、Offset、ROI 集合自定义控件要给外部绑定的东西最好走依赖属性别用普通 CLR 属性。ImageSource变了要触发重绘Scale和Offset变了也要触发重绘这些用FrameworkPropertyMetadataOptions.AffectsRender就能自动搞定。下面这三个属性是图像层的核心契约public static readonly DependencyProperty ImageSourceProperty DependencyProperty.Register( nameof(ImageSource), typeof(ImageSource), typeof(ImageCanvas), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.AffectsRender, OnImageSourceChanged)); public static readonly DependencyProperty ScaleProperty DependencyProperty.Register( nameof(Scale), typeof(double), typeof(ImageCanvas), new FrameworkPropertyMetadata( 1.0, FrameworkPropertyMetadataOptions.AffectsRender, OnViewChanged, CoerceScale)); public static readonly DependencyProperty OffsetProperty DependencyProperty.Register( nameof(Offset), typeof(Point), typeof(ImageCanvas), new FrameworkPropertyMetadata( new Point(0, 0), FrameworkPropertyMetadataOptions.AffectsRender, OnViewChanged)); private static object CoerceScale(DependencyObject d, object baseValue) { var value (double)baseValue; return Math.Clamp(value, 0.05, 64.0); } private static void OnImageSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var canvas (ImageCanvas)d; canvas.Scale 1.0; canvas.Offset new Point(0, 0); }AffectsRender的意思是属性变化后 WPF 自动调InvalidateVisual不用手写刷新。CoerceScale把缩放限制在0.05到64之间防止用户滚轮一滑把图缩成一个小点或者放大到坐标溢出。OnImageSourceChanged里重置视图换图时回到 1:1这是仿商业控件的行为如果业务上希望换图保持视图把这两行去掉就行。OnViewChanged里我一般还会做一次ClampOffset让图像不会被拖出可视区太远至少保留 48 像素在窗口内否则用户很容易把图拖丢只能靠「重置视图」菜单找回来。RoiManager不建议做成依赖属性它更像一个普通服务对象。RoiOverlay暴露一个RoiManager属性MainWindow在 XAML 里绑上去或者直接在后台代码里赋值。ROI 集合内部用ObservableCollectionRoiBase但RoiOverlay不监听集合的每一个变化去重建视觉树而是在鼠标操作结束后统一InvalidateVisual。这样拖拽过程中不会因为集合通知触发大量布局计算帧率会稳很多。2.4 坐标系统图像坐标、控件坐标、DIP 与像素坐标系统是这套控件最容易埋雷的地方。我的原则是ROI 模型里只存图像坐标单位就是像素左上角(0,0)控件坐标是 WPF 的 DIPOnRender和鼠标事件拿到的都是 DIP两者之间只通过ImageToControl和ControlToImage转换任何地方都不要手写p.X * Scale Offset.X。下面这个 mapper 是整条链路的核心public interface ICoordinateMapper { double Scale { get; } Point ImageToControl(Point imagePoint); Point ControlToImage(Point controlPoint); } public sealed class CoordinateMapper : ICoordinateMapper { public double Scale { get; init; } 1.0; public Point Offset { get; init; } public Point ImageToControl(Point p) new Point(p.X * Scale Offset.X, p.Y * Scale Offset.Y); public Point ControlToImage(Point p) new Point((p.X - Offset.X) / Scale, (p.Y - Offset.Y) / Scale); }ImageToControl先乘Scale再加OffsetControlToImage反过来先减Offset再除Scale。这两个方法是互逆的后面单元测试会专门验证 round-trip。滚轮缩放时为了让鼠标下的图像点不动需要先记下鼠标位置的图像坐标再算新的Offsetprotected override void OnMouseWheel(MouseWheelEventArgs e) { var controlPoint e.GetPosition(this); var imagePoint ControlToImage(controlPoint); var factor e.Delta 0 ? 1.1 : 1.0 / 1.1; var newScale Math.Clamp(Scale * factor, 0.05, 64.0); // 保持鼠标下的图像点不动 Offset new Point( controlPoint.X - imagePoint.X * newScale, controlPoint.Y - imagePoint.Y * newScale); Scale newScale; e.Handled true; }这段的逻辑是「以鼠标为锚点缩放」先通过旧视图把鼠标位置转成图像坐标再用新缩放算出让这个图像点仍然落在鼠标位置的Offset。factor取 1.1 和 1/1.1滚轮一格大约 10%手感比较接近商业控件Math.Clamp和CoerceScale双重保险防止外部直接设属性绕过限制。注意Offset是PointPoint Vector在 WPF 里有运算符重载但这里手动构造更直观。高 DPI 下e.GetPosition(this)仍然是 DIP只要图像 DPI 固定 96这套换算就是一致的如果混用了PixelWidth和ActualWidth就会出现「1080P 屏幕上 ROI 偏 1.25 倍」的玄学问题第 4 章会专门讲。3. ROI 绘制与交互命中测试、句柄、拖拽状态机怎么串起来3.1 ROI 模型设计一个基类管住矩形、圆、多边形、线段ROI 基类不需要太厚但六个方法必须定清楚怎么画、怎么命中、怎么移动、怎么缩放、句柄在哪、怎么序列化。我把命中测试的容差参数设计成图像坐标单位而不是控件像素这样缩放后不用改调用方。基类里只放Id、Name、Stroke、StrokeThickness、IsSelected这些公共状态具体几何数据放子类。下面这个抽象类是整套 ROI 的地基public abstract class RoiBase { public string Id { get; set; } Guid.NewGuid().ToString(N); public string Name { get; set; } ROI; public Color Stroke { get; set; } Colors.Lime; public double StrokeThickness { get; set; } 1.5; public bool IsSelected { get; set; } public abstract Rect ImageBounds { get; } public abstract void Draw(DrawingContext dc, ICoordinateMapper mapper); public abstract bool HitTest(Point imagePoint, double toleranceInImage); public abstract void Move(Vector imageDelta); public abstract void Resize(HandlePosition handle, Point imagePoint); public abstract IEnumerableHandleInfo GetHandles(ICoordinateMapper mapper); public abstract RoiDto ToDto(); } public enum HandlePosition { None, TopLeft, Top, TopRight, Right, BottomRight, Bottom, BottomLeft, Left } public record HandleInfo(HandlePosition Position, Point ControlPoint);Draw接收DrawingContext和ICoordinateMapperROI 自己负责把图像坐标转成控件坐标再画这样RoiOverlay不用知道每种 ROI 的几何细节。HitTest的toleranceInImage由调用方用6 / Scale算出来缩放越小容差越大保证句柄在视觉上始终是 6 个像素左右。GetHandles返回的是控件坐标下的句柄位置命中测试和绘制共用同一份数据避免「画在这里、点在那里」的错位。ToDto返回纯数据对象不依赖 WPF 类型序列化时不会把Color、Point这些结构扯进去。ImageBounds这个属性很关键它返回 ROI 在图像坐标系下的外接矩形。移动、裁剪、判断 ROI 是否超出图像边界、导出 mask 都要用它。对于矩形 ROI它就是Rect(TopLeft, BottomRight)对于圆是Center ± Radius对于多边形是点集的最小外接矩形。基类不实现它因为每种几何的算法不一样但所有子类都必须给出一致的语义左上角小于右下角宽高非负。3.2 矩形 ROI 实现与句柄8 个控制点怎么算、Resize 怎么规范化矩形 ROI 是最常用的也是句柄最多的。8 个控制点分别是左上、上、右上、右、右下、下、左下、左。绘制时先画矩形边框选中状态下再画 8 个 8x8 的小方块。命中测试先判断点是否落在矩形内部或边框容差内句柄命中则放在控件层做因为句柄大小是控件像素不应该受图像缩放影响。下面这段是RectRoi的核心实现public sealed class RectRoi : RoiBase { public Point TopLeft { get; set; } public Point BottomRight { get; set; } public override Rect ImageBounds new Rect(TopLeft, BottomRight); public override void Draw(DrawingContext dc, ICoordinateMapper mapper) { var p1 mapper.ImageToControl(TopLeft); var p2 mapper.ImageToControl(BottomRight); var pen new Pen(new SolidColorBrush(Stroke), StrokeThickness); dc.DrawRectangle(null, pen, new Rect(p1, p2)); if (!IsSelected) return; var handlePen new Pen(Brushes.Lime, 1); foreach (var handle in GetHandles(mapper)) { var rect new Rect( handle.ControlPoint.X - 4, handle.ControlPoint.Y - 4, 8, 8); dc.DrawRectangle(Brushes.White, handlePen, rect); } } public override bool HitTest(Point p, double toleranceInImage) { var r ImageBounds; var expanded new Rect( r.Left - toleranceInImage, r.Top - toleranceInImage, r.Width toleranceInImage * 2, r.Height toleranceInImage * 2); return expanded.Contains(p); } public override void Move(Vector imageDelta) { TopLeft imageDelta; BottomRight imageDelta; } public override void Resize(HandlePosition handle, Point p) { var r ImageBounds; double left r.Left, top r.Top, right r.Right, bottom r.Bottom; switch (handle) { case HandlePosition.TopLeft: left p.X; top p.Y; break; case HandlePosition.Top: top p.Y; break; case HandlePosition.TopRight: right p.X; top p.Y; break; case HandlePosition.Right: right p.X; break; case HandlePosition.BottomRight: right p.X; bottom p.Y; break; case HandlePosition.Bottom: bottom p.Y; break; case HandlePosition.BottomLeft: left p.X; bottom p.Y; break; case HandlePosition.Left: left p.X; break; } TopLeft new Point(Math.Min(left, right), Math.Min(top, bottom)); BottomRight new Point(Math.Max(left, right), Math.Max(top, bottom)); } public override IEnumerableHandleInfo GetHandles(ICoordinateMapper mapper) { var r ImageBounds; yield return new HandleInfo(HandlePosition.TopLeft, mapper.ImageToControl(r.TopLeft)); yield return new HandleInfo(HandlePosition.Top, mapper.ImageToControl(new Point(r.Left r.Width / 2, r.Top))); yield return new HandleInfo(HandlePosition.TopRight, mapper.ImageToControl(r.TopRight)); yield return new HandleInfo(HandlePosition.Right, mapper.ImageToControl(new Point(r.Right, r.Top r.Height / 2))); yield return new HandleInfo(HandlePosition.BottomRight, mapper.ImageToControl(r.BottomRight)); yield return new HandleInfo(HandlePosition.Bottom, mapper.ImageToControl(new Point(r.Left r.Width / 2, r.Bottom))); yield return new HandleInfo(HandlePosition.BottomLeft, mapper.ImageToControl(r.BottomLeft)); yield return new HandleInfo(HandlePosition.Left, mapper.ImageToControl(new Point(r.Left, r.Top r.Height / 2))); } public override RoiDto ToDto() new RoiDto(rect, Id, Name, new[] { TopLeft.X, TopLeft.Y, BottomRight.X, BottomRight.Y }, Stroke.ToString()); }Draw里new Rect(p1, p2)会自动规范化即使p2比p1小也能画出正确的矩形但Resize里不能依赖这个必须手动Math.Min/Max否则TopLeft和BottomRight的语义会反过来后面的ImageBounds、Move、序列化全乱。GetHandles的 8 个点里上、下、左、右四个中点是用r.Left r.Width / 2算的不要用TopLeft.X (BottomRight.X - TopLeft.X) / 2两者等价但前者在规范化后更稳。HitTest把矩形向外扩toleranceInImage这样点击边框附近也能选中用户体验比必须点中细线好很多。ToDto里传的是double[]矩形四个数圆三个数多边形2N个数。反序列化时按Type字段走工厂rect建RectRoicircle建CircleRoipolygon建PolygonRoi。DTO 里加一个Version字段哪怕当前只有 1后面加旋转矩形、带方向的线段时旧文件也能走兼容分支。这个字段就是后悔药等标注文件堆了几千份再想加就晚了。3.3 鼠标状态机Idle、Drawing、Dragging、Resizing 怎么切鼠标交互最怕写成一堆if嵌套。我一般用一个DragMode枚举把状态收住None、Pan、DrawRect、MoveRoi、ResizeRoi。OnMouseLeftButtonDown里按优先级判断先看有没有点中当前选中 ROI 的句柄再看有没有点中某个 ROI 内部最后看空白处是不是在画新 ROI。句柄优先于 ROI 内部ROI 内部优先于空白这个顺序不能反否则选中 ROI 后永远拖不动句柄。下面这段是核心状态切换private enum DragMode { None, Pan, DrawRect, MoveRoi, ResizeRoi } private DragMode _mode; private RoiBase _activeRoi; private HandlePosition _activeHandle; private Point _lastImagePoint; protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { Focus(); CaptureMouse(); var controlPoint e.GetPosition(this); var imagePoint _mapper.ControlToImage(controlPoint); var tolerance 6 / _mapper.Scale; _activeRoi RoiManager.HitTest(imagePoint, tolerance); if (_activeRoi ! null) { var handle _activeRoi.GetHandles(_mapper) .FirstOrDefault(h (h.ControlPoint - controlPoint).Length 6); _activeHandle handle?.Position ?? HandlePosition.None; _mode _activeHandle ! HandlePosition.None ? DragMode.ResizeRoi : DragMode.MoveRoi; _lastImagePoint imagePoint; RoiManager.Select(_activeRoi); InvalidateVisual(); e.Handled true; return; } if (Tool RoiTool.Rect) { var roi new RectRoi { TopLeft imagePoint, BottomRight imagePoint }; RoiManager.Add(roi); _activeRoi roi; _mode DragMode.DrawRect; e.Handled true; } } protected override void OnMouseMove(MouseEventArgs e) { var controlPoint e.GetPosition(this); var imagePoint _mapper.ControlToImage(controlPoint); var previous _lastImagePoint; _lastImagePoint imagePoint; switch (_mode) { case DragMode.DrawRect when _activeRoi is RectRoi rect: rect.BottomRight imagePoint; InvalidateVisual(); break; case DragMode.MoveRoi when _activeRoi ! null: _activeRoi.Move(imagePoint - previous); InvalidateVisual(); break; case DragMode.ResizeRoi when _activeRoi ! null: _activeRoi.Resize(_activeHandle, imagePoint); InvalidateVisual(); break; } } protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { if (_mode DragMode.DrawRect _activeRoi is RectRoi rect (rect.ImageBounds.Width 2 || rect.ImageBounds.Height 2)) { RoiManager.Remove(rect); } _mode DragMode.None; _activeRoi null; _activeHandle HandlePosition.None; ReleaseMouseCapture(); e.Handled true; }OnMouseLeftButtonDown里先CaptureMouse保证鼠标拖出控件边界还能收到MouseMove和MouseUp否则快速拖拽时状态机会卡在MoveRoi出不来。tolerance 6 / _mapper.Scale是把控件上的 6 像素容差换算成图像坐标缩放 10 倍时容差变成 0.6 个图像像素命中依然准。句柄命中用(h.ControlPoint - controlPoint).Length 6这里比较的是控件坐标所以不能用图像容差。OnMouseMove里先保存previous再更新_lastImagePoint移动量用imagePoint - previous顺序反了就会变成「越拖越快」或者「拖不动」。OnMouseLeftButtonUp里对太小的矩形做移除避免用户单击一下留下一个 0x0 的 ROI后面导出 mask 时全是空区域。中键平移单独走一个分支OnMouseDown里判断e.ChangedButton MouseButton.Middle把_mode设成Pan记录_lastControlPointMouseMove里改Offset。右键用来取消当前绘制OnMouseRightButtonDown里如果_mode DrawRect就把刚建的 ROI 移除状态复位。这些分支不要和左键状态机混在一个switch里分开写更清楚。3.4 绘制层用 DrawingVisual 还是 OnRenderROI 数量少的时候直接在RoiOverlay.OnRender里遍历RoiManager.Items调Draw最简单代码少调试直观。数量超过两三百个或者每个 ROI 的点很多全量重绘就会吃 CPU这时候再考虑DrawingVisual缓存。我的做法是先上OnRender用性能计数器看帧率真的卡了再换不提前优化。下面这段是RoiOverlay的绘制入口和RoiManager的倒序命中protected override void OnRender(DrawingContext dc) { base.OnRender(dc); if (RoiManager null) return; foreach (var roi in RoiManager.Items) roi.Draw(dc, _mapper); DrawDebugOverlay(dc); } public sealed class RoiManager { public ObservableCollectionRoiBase Items { get; } new(); public RoiBase Selected { get; private set; } public void Add(RoiBase roi) { Items.Add(roi); Select(roi); } public void Remove(Roi p a hrefhttps://download.csdn.net/download/weixin_43678368/90990432 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑