WPF基于Canvas绘制多边形ROI

ROI(Region of Interest), 指的是在一幅图像或视频帧中,你特别关注、需要进一步处理或分析的那个部分。它通常是图像中的一个矩形区域(当然也可以是其他形状,如多边形、圆形,或通过掩码定义的任意形状)。

本文基于WPF的Canvas绘制多边形ROI。效果图如下

首先定义一个用来绘制的工具

csharp 复制代码
 internal class PolygonROIDrawingVisual : DrawingVisual
{
    private readonly double _radius = 5.0;

    /// <summary>
    /// 绘制多边形边框
    /// </summary>
    public void Draw(List<Point> points, Brush brushes = null)
    {
        Pen _strokePen = new Pen(brushes ?? Brushes.Red, 2.0);
        using (DrawingContext dc = RenderOpen())
        {
            if (points.Count == 0)
            {
                return;
            }
            if (points.Count == 1)
            {
                dc.DrawEllipse(brushes ?? Brushes.Red, _strokePen, points[0], _radius, _radius);
                return;
            }

            // 创建多边形边框
            StreamGeometry geometry = new StreamGeometry();

            using (StreamGeometryContext ctx = geometry.Open())
            {
                // 移动到第一个点
                ctx.BeginFigure(points[0], false, points.Count >= 3); // 只有3个以上点才闭合

                // 绘制所有线段
                for (int i = 1; i < points.Count; i++)
                {
                    ctx.LineTo(points[i], true, false);
                }
            }

            // 绘制边框
            dc.DrawGeometry(null, _strokePen, geometry);

            // 绘制顶点
            foreach (Point point in points)
            {
                dc.DrawEllipse(brushes ?? Brushes.Red, _strokePen, point, _radius, _radius);
            }
        }
    }
}

定义画布 Canvas

csharp 复制代码
    public enum PolygonROIOperateType
    {
        None,
        ReadyToDraw,
        DrawDone,
    }

    public class PolygonROICanvas : Canvas
    {
        private readonly int _minGap = 10; // 两顶点的最小距离

        private Point lastPoint;
        private readonly PolygonROIDrawingVisual roi;
        private PolygonROIOperateType operate = PolygonROIOperateType.None;
        protected override int VisualChildrenCount => 1;

        protected override Visual GetVisualChild(int index)
        {
            return roi;
        }

        public PolygonROICanvas()
        {
            roi = new PolygonROIDrawingVisual();
            this.AddLogicalChild(roi);
            this.AddVisualChild(roi);
            Background = Brushes.Transparent;
            PointCollection = new ObservableCollection<Point>();
        }

        /// <summary>
        /// 是否可以开始绘制ROI
        /// </summary>
        public bool EnableDraw
        {
            get { return (bool)GetValue(EnableDrawProperty); }
            set { SetValue(EnableDrawProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Enable.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty EnableDrawProperty =
            DependencyProperty.Register("EnableDraw",
                typeof(bool), typeof(PolygonROICanvas),
                new PropertyMetadata(false, OnEnableDrawPropertyChanged));

        private static void OnEnableDrawPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is not PolygonROICanvas canvas)
                return;
            if (e.NewValue == e.OldValue)
            {
                return;
            }
            if (e.NewValue is bool enable && enable == true)
            {
                canvas.operate = PolygonROIOperateType.ReadyToDraw;
                canvas.PointCollection.Clear();
            }
        }

        public static readonly DependencyProperty PointCollectionProperty =
           DependencyProperty.Register(
               nameof(PointCollection),
               typeof(ObservableCollection<Point>),
               typeof(PolygonROICanvas),
               new PropertyMetadata(null,
                  OnItemsPropertyChanged));

        public ObservableCollection<Point> PointCollection
        {
            get => (ObservableCollection<Point>)GetValue(PointCollectionProperty);
            set => SetValue(PointCollectionProperty, value);
        }

        private static void OnItemsPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is not PolygonROICanvas canvas)
                return;

            if (e.OldValue is ObservableCollection<Point> oldCollection)
            {
                oldCollection.CollectionChanged -= canvas.PointCollection_CollectionChanged;
            }

            if (e.NewValue is ObservableCollection<Point> newCollection)
            {
                newCollection.CollectionChanged += canvas.PointCollection_CollectionChanged;
            }

            // 初始更新(包括 newCollection == null 的情况)
            canvas.UpdateDrawing();
        }

        private void PointCollection_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
        {
            UpdateDrawing();
        }

        private void UpdateDrawing()
        {
            // 确保在 UI 线程执行绘制
            if (!Dispatcher.CheckAccess())
            {
                Dispatcher.Invoke(UpdateDrawing);
                return;
            }

            var points = PointCollection?.ToList();
            if (points == null || points.Count == 0)
            {
                // 清空绘制(传入空列表)
                roi.Draw(new List<Point>());
                return;
            }

            roi.Draw(points);
        }
        .....
     }

主要的属性 EnableDraw 用来启动绘画; PointCollection 用来存放多边形的顶点。

绘制的逻辑 主要是在Canvas的 MouseMove 和 MouseLeftButtonDown事件中处理。

MouseLeftButtonDown 主要是单击添加顶点 和 双击结束绘画

arduino 复制代码
       private DateTime _lastClickTime;
       private const int DoubleClickThreshold = 200; // 双击时间间隔阈值,单位为毫秒

       protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
       {
           base.OnMouseLeftButtonDown(e);
           // 双击
           if ((DateTime.Now - _lastClickTime).TotalMilliseconds < DoubleClickThreshold)
           {
               EnableDraw = false;
               operate = PolygonROIOperateType.DrawDone;
               e.Handled = true;
           }
           else // 单击
           {
               if (operate == PolygonROIOperateType.ReadyToDraw)
               {
                   Point point = e.GetPosition(this);
                   if (point.X < 0 || point.X > this.ActualWidth)
                   {
                       return;
                   }
                   if (point.Y < 0 || point.Y > this.ActualHeight)
                   {
                       return;
                   }
                   if (IsPointTooClose(point, PointCollection?.ToList()))
                   {
                       return;
                   }
                   // 单击增加点
                   PointCollection.Add(point);
                   lastPoint = e.GetPosition(this);
               }
           }
           _lastClickTime = DateTime.Now;
       }

       // 判断新点是否与已有点过近
       private bool IsPointTooClose(Point newPoint, List<Point> existingPoints)
       {
           if (existingPoints == null || existingPoints.Count == 0)
           {
               return false;
           }
           foreach (var point in existingPoints)
           {
               if (GetDistance(newPoint, point) < _minGap)
                   return true;
           }
           return false;
       }

       private double GetDistance(Point p1, Point p2)
       {
           double dx = p1.X - p2.X;
           double dy = p1.Y - p2.Y;
           return System.Math.Sqrt(dx * dx + dy * dy);
       }

添加顶点的时候要考虑边界问题以及现有点的重复问题。

MouseMove事件主要是处理 绘图中的实时绘制 以及 平移顶点和整个多边形。

ini 复制代码
     protected override void OnMouseMove(MouseEventArgs e)
     {
         Point point = e.GetPosition(this);
         if (operate == PolygonROIOperateType.ReadyToDraw)
         {
             List<Point> tempList = PointCollection?.ToList();
             if (tempList == null || tempList.Count == 0)
             {
                 return;
             }
             tempList.Add(point);
             tempList = SortPointsForClosedPolygon(tempList);
             roi.Draw(tempList);
         }
         else if (operate == PolygonROIOperateType.DrawDone)
         {
             var res = GetNearestPoint(point, PointCollection?.ToList());
             if (res.Item1 >= 0 && res.Item2 <= 8) // 是否选中点
             {
                 this.Cursor = Cursors.Hand;
                 if (Mouse.LeftButton == MouseButtonState.Pressed)
                 {
                     double x = point.X;
                     double y = point.Y;
                     if (x < 5)
                     {
                         x = 5;
                     }
                     else if (x > this.ActualWidth - 5)
                     {
                         x = this.ActualWidth - 5;
                     }
                     if (y < 5)
                     {
                         y = 5;
                     }
                     else if (y > this.ActualHeight - 5)
                     {
                         y = this.ActualHeight - 5;
                     }
                     // 移动点
                     PointCollection[res.Item1] = new Point(x, y);
                     //对集合进行重新排序,保证多边形闭合且不自交
                     PointCollection = new ObservableCollection<Point>(SortPointsForClosedPolygon(PointCollection?.ToList()));
                     roi.Draw(PointCollection?.ToList());
                 }
             }
             else if (IsPointInPolygon(point, PointCollection?.ToList())) // 是否在多边形内
             {
                 this.Cursor = Cursors.SizeAll;
                 if (Mouse.LeftButton == MouseButtonState.Pressed)
                 {
                     double minX = PointCollection.Min(p => p.X);
                     double minY = PointCollection.Min(p => p.Y);
                     double maxX = PointCollection.Max(p => p.X);
                     double maxY = PointCollection.Max(p => p.Y);
                     double stepX = point.X - lastPoint.X;
                     double stepY = point.Y - lastPoint.Y;
                     double finalXStep = stepX;
                     double finalYStep = stepY;
                     if (stepX < 0) // 左移
                     {
                         if (minX + stepX < 5)
                         {
                             finalXStep = 5 - minX;
                         }
                     }
                     else // 右
                     {
                         if (maxX + stepX > this.ActualWidth - 5)
                         {
                             finalXStep = this.ActualWidth - 5 - maxX;
                         }
                     }

                     if (stepY < 0) // 上
                     {
                         if (minY + stepY < 5)
                         {
                             finalYStep = 5 - minY;
                         }
                     }
                     else // 下
                     {
                         if (maxY + stepY > this.ActualHeight - 5)
                         {
                             finalYStep = this.ActualHeight - 5 - maxY;
                         }
                     }

                     PointCollection = new ObservableCollection<Point>(
                         PointCollection.Select(point => new Point(point.X + finalXStep, point.Y + finalYStep)));
                     roi.Draw(PointCollection?.ToList());
                 }
             }
             else
             {
                 this.Cursor = Cursors.Arrow;
             }
         }
         lastPoint = point;
     }

平移顶点时要考虑边界问题,不要将顶点以及多边形区域移出canvas区域。

相关推荐
缺点内向3 小时前
如何在 C# 中重命名 Excel 工作表并设置标签颜色
开发语言·c#·excel
a努力。4 小时前
网易Java面试被问:偏向锁在什么场景下反而降低性能?如何关闭?
java·开发语言·后端·面试·架构·c#
专注VB编程开发20年5 小时前
c#语法和java相差多少
java·开发语言·microsoft·c#
SmoothSailingT6 小时前
C#——Lazy<T>懒加载机制
开发语言·单例模式·c#·懒加载
czhc11400756636 小时前
c# 1216
windows·microsoft·c#
幸存者letp6 小时前
为什么 max(words, key=len) 中需要传 key=len
服务器·开发语言·c#
SmoothSailingT7 小时前
C#——Interface(接口)
开发语言·c#·接口
Henry_Wu0017 小时前
go与c# 及nats和rabbitmq交互
golang·c#·rabbitmq·grpc·nats
烛阴8 小时前
深入 C# 字符串世界:基础语法、常用方法与高阶实战
前端·c#