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区域。